# Create several customers (deprecated) API Reference

Creates up to 500 customers in a single call.

- **Deprecated:** use `POST /v1/companies/{company_id}/customers/bulk`, which validates
  and creates the same way. A dry run still answers `200`; an actual creation answers
  `201` instead of `200`.
- **Atomic:** if any customer fails validation the whole batch is rejected with
  `422 BULK_VALIDATION_ERROR` and nothing is persisted.
- **`dry_run`:** with `true` the batch is only validated — tax identifiers against the
  AEAT register, duplicates inside the batch and against the existing customers, field
  formats — and nothing is written. With `false`, the default, validation is followed by
  creation.
- **Report:** both modes return the same per-record report, so a dry run and a real run
  are read the same way.

**Retires on 9 December 2026.** See the [migration guide](https://docs.beel.es/changelog/resources-under-the-nif) for what moved where and what changes when you switch.


## POST /v1/customers/bulk

**Create several customers**

Creates up to 500 customers in a single call.

- **Deprecated:** use `POST /v1/companies/{company_id}/customers/bulk`, which validates
  and creates the same way. A dry run still answers `200`; an actual creation answers
  `201` instead of `200`.
- **Atomic:** if any customer fails validation the whole batch is rejected with
  `422 BULK_VALIDATION_ERROR` and nothing is persisted.
- **`dry_run`:** with `true` the batch is only validated — tax identifiers against the
  AEAT register, duplicates inside the batch and against the existing customers, field
  formats — and nothing is written. With `false`, the default, validation is followed by
  creation.
- **Report:** both modes return the same per-record report, so a dry run and a real run
  are read the same way.

**Retires on 9 December 2026.** See the [migration guide](https://docs.beel.es/changelog/resources-under-the-nif) for what moved where and what changes when you switch.

**Deprecated.**

### Authentication

Accepts any of:

- `ApiKeyAuth` (HTTP bearer, token format `beel_sk_*`)

### Parameters

- **Idempotency-Key** (optional) in header `string`: Idempotency key to prevent duplicates in sensitive operations. - Any unique client-generated string (e.g. an order id). A UUID also works but is not required - Allowed characters: letters, digits, `_` and `-` (max 255 chars) - Retrying with the same key replays the first response when it was a success (2xx) or a server error (5xx): same status and body, plus the header `Idempotency-Replay: true`. After a 5xx, check whether the operation took effect before retrying with a **new** key - A 4xx is not stored: the key is released, so the corrected request can reuse it - Stored responses expire 24 hours after processing The key is scoped per user and environment, and bound to the request body, so retrying after a network timeout replays the stored response instead of repeating the operation. | Status | Code | When | |---|---|---| | `400` | `INVALID_IDEMPOTENCY_KEY` | The key breaks the format rules above. | | `409` | `IDEMPOTENCY_KEY_PROCESSING` | The first request is still in flight. Wait for the `Retry-After` seconds (2) and retry with the same key. | | `409` | `IDEMPOTENCY_KEY_MISMATCH` | The key was already used with a **different** body. Use a new key. |
- **dry_run** (optional) in query `boolean` (default: false): Validate the batch without persisting it (`true`), or validate and create it (`false`, the default). Either way the batch is atomic.

### Request Body

Required.

**Content `application/json`:**

- **customers** (required) `array[CreateCustomerRequest]`: No description

### Responses

#### 200: Validation completed successfully (dry_run=true).
Returns validation results without persisting data using unified format.


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: true)
- **data** (required): The payload. An object for a single resource; an object holding the named collection (and its `pagination`) for a listing. Never a bare array at this level in v1.
- **meta** `ResponseMeta`
- **data** `CustomerValidationUnifiedResult`: 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.

#### 201: Customers created (`dry_run=false`). Atomic operation: either every customer of the
batch was created, or none was. The body is the same unified report as the `200` and as
the successor route.


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: true)
- **data** (required): The payload. An object for a single resource; an object holding the named collection (and its `pagination`) for a listing. Never a bare array at this level in v1.
- **meta** `ResponseMeta`
- **data** `CustomerValidationUnifiedResult`: 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.

#### 400: Bad request. From a dashboard session, a `BeeL-Environment` header whose value is not a
known environment answers `ENVIRONMENT_HEADER_INVALID`, with the header named in
`error.details` (`{"BeeL-Environment": "invalid"}`); it is never read as production.

`BUSINESS_RULE_VIOLATION` is the generic answer to an operation the resource does not
admit in its current state when that state has no code of its own. It carries no
`details`; read the resource again before retrying.


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "BAD_REQUEST",
    "message": "Invalid request"
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

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


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication is required to access this resource"
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

#### 403: Authenticated but not allowed. Fourteen causes shared by every operation, told apart by
`error.code`; an operation that adds a cause of its own names it in its `403`. You can
branch on them exhaustively.

- `INSUFFICIENT_SCOPE` — the credential lacks a scope the operation requires;
  `error.details.missing_scopes` names them as a single comma-separated string (for
  example `"invoices:write,customers:read"`), not as an array; `required_scopes` has the
  same shape and lists every scope the operation needs. Retrying will not help: mint a key
  that holds them.
- `COMPANY_READ_ONLY` — the scope is there, but your access level over that NIF only
  lets you read it.
- `ACCOUNT_MANAGEMENT_FORBIDDEN` — the scope is there, but your role over the account,
  or the management relationship you hold over it, does not cover this operation.
- `ACCOUNT_NOT_ACCESSIBLE` — the account is not yours to reach, which is also the answer
  when it does not exist, so existence is never disclosed.
- `ACTIVE_COMPANY_NOT_ACCESSIBLE` — the same, for a NIF: the company in the path, or the
  one named by `BeeL-Active-Company`, is not one this credential may reach — and again,
  this is also the answer when it does not exist.
- `COMPANY_ACCESS_REVOKED` — your access to the NIF was withdrawn while the request was
  already in flight, so the write was rejected and nothing was recorded. Retrying will
  not help until the access is granted again.
- `LIVE_CREDENTIAL_REQUIRED` — the operation changes the real account and the call came
  from a test API key (`beel_sk_test_…`). Use your live key or the dashboard.
- `FEATURE_NOT_AVAILABLE` — your subscription does not include the entitlement the
  operation needs; `error.details.feature_code` names it. This gate runs **before** the
  scope gate, so for such an operation you never see `INSUFFICIENT_SCOPE` first.
- `NO_ACTIVE_ACCOUNT` — the credential does not resolve to an account, so no scope can be
  evaluated against one. Fail-closed, not a permission that can be granted to you.
- `OPERATION_REQUIRES_SESSION` — the operation is available only from the web session; no
  API key and no OAuth2 token can perform it, whatever scopes it holds.
- `OPERATION_REQUIRES_API_KEY` — the reverse: the operation needs a scope that only an API
  key can hold (such as `accounts:write`), so a dashboard session never satisfies it.
- `API_KEY_LIVE_NOT_AVAILABLE` — the call came with a live API key (`beel_sk_live_…`) and
  the account has no Live mode yet. It is never degraded to Test: use your test key, or
  activate Live first.
- `ACCOUNT_REQUIRES_FOCUS_SWITCH` — dashboard session only: you are a member of the account
  in the path, but it is not the account in focus. Switch focus to it and retry.
- `REQUEST_BLOCKED` — the edge security filter stopped the request before the API saw its
  body, usually because a text field carries something that looks like markup or a
  script (HTML tags, `<script>`). Review the body and retry.


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "FORBIDDEN",
    "message": "You do not have permission to access this resource"
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

#### 422: Validation error in one or more customers

**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")
- **error** `object`: No description
  - **code** `string`: No description (example: "BULK_VALIDATION_ERROR")
  - **message** `string`: No description
  - **errors** `array[object]`: No description
    - **index** `integer`: Index of the customer with error (0-based)
    - **field** `string`: No description
    - **message** `string`: No description

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


**Headers:**

- `Retry-After` `integer`: Seconds until the rate limit resets
- `RateLimit-Limit` `integer`: Maximum requests allowed in the window
- `RateLimit-Remaining` `integer`: Remaining requests in the current window
- `RateLimit-Reset` `integer`: Seconds until the current window resets

**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please try again in 60 seconds."
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

#### 500: Internal server error

**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Internal server error"
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

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


**Content `application/json`:**

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "UNSUPPORTED_MEDIA_TYPE",
    "message": "Unsupported media type: text/plain. Supported: application/json"
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

---

# Related Schema Definitions

## CreateCustomerRequest

- **legal_name** (required) `string`: Customer legal name (required). When you also send a Spanish `nif`, how it is used depends on the customer: for an **individual** the AEAT census matches NIF and name together, so a name it does not recognise makes the customer invalid; for a **company** the name is **not verified** and only the CIF decides.
- **trade_name** `string`: Customer trade name. Optional, but **not empty by default**: leave it out on creation and it is filled with `legal_name`, which is what then shows as the recipient's trade name on the invoice PDF. Send it explicitly if the two differ. The default applies **on creation only**. A `PUT` replaces the customer whole, so omitting `trade_name` there **clears** it instead of refilling it from `legal_name`.
- **nif**: Spanish Tax ID (required if id_otro is not provided)
- **alternative_id** `AlternativeIdentifier`: 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)  | ✓                   | ✗                   |
- **address** (required) `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.
- **phone** `PhoneInput`: A phone number as this API accepts it: 9 to 20 characters, and only digits, spaces, dashes, parentheses and an optional leading `+`. Every request that takes a phone number uses this schema. It is `Phone` plus the rules enforced on input. A value this schema accepts always satisfies `Phone`, so anything you send here is something a response can return.
- **email** `string` (email): 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`: Website URL
- **billing_emails** `array[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`: Contact person name (optional)
- **notes** `string`: Additional notes about the customer (optional)
- **preferred_payment_method** `PaymentInfo`
- **general_discount** `number`: General discount percentage (optional)

## SuccessResponse

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** (required) `boolean`: No description (example: true)
- **data** (required): The payload. An object for a single resource; an object holding the named collection (and its `pagination`) for a listing. Never a bare array at this level in v1.
- **meta** `ResponseMeta`

## ResponseMeta

- **timestamp** `string` (date-time): No description (example: "2025-01-15T10:30:00Z")
- **request_id** `string`: No description (example: "4bf92f3577b34da6a3ce929d0e0e4736")

## CustomerValidationUnifiedResult

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** (required) `CustomerValidationMetadata`: Metadata about the validation operation. Compatible with bulk JSON, CSV import and Holded Excel import.
- **customers_validation** (required) `array[CustomerValidationItem]`: List of processed customers with their validation status
- **statistics** (required) `CustomerValidationStatistics`: 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.

## ErrorResponse

Error response shared by all BeeL. APIs.

The payload carries **two contracts at once** (additive, non-breaking):

- **Legacy** (`success`, `error.{code,message,details}`, `meta`) — kept
  intact for existing consumers.
- **RFC 9457** (`type`, `title`, `detail`, `instance`) — new fields
  for integrators following Problem Details for HTTP APIs. The
  `type` URI is the stable, shareable link to the error's
  documentation page (e.g. `https://docs.beel.es/errors/{code}`).

Future migration: the legacy fields will be deprecated via
`Deprecation`/`Sunset` headers after a sufficient adoption window,
and the response Content-Type will move to
`application/problem+json`.

- **success** (required) `boolean`: No description (example: false)
- **error** (required) `ErrorDetail`
- **meta** `ResponseMeta`
- **type** `string` (uri): Stable URI that identifies the problem type and where the integrator will find its documentation. (example: "https://docs.beel.es/errors/INVOICE_NO_LINES")
- **title** `string`: Short summary of the problem type. Stable between occurrences of the same `type`. (example: "INVOICE_NO_LINES")
- **detail** `string`: Specific message for this occurrence, localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English. Matches the legacy `error.message`. (example: "The invoice must have at least one line")
- **instance** `string` (uri-reference): URI that identifies the specific occurrence of the problem — typically the path of the affected resource. (example: "/v1/invoices/abc-123")

## ErrorDetail

- **code** (required) `string`: No description (example: "VALIDATION_ERROR")
- **message** (required) `string`: No description (example: "The provided data is not valid")
- **details** `object`: No description (example: {"field":"specific error message"})

## AlternativeIdentifier

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** (required) `string`: 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. — one of: NIF_IVA, PASSPORT, COUNTRY_ID, RESIDENCE_CERTIFICATE, OTHER_DOCUMENT, NOT_REGISTERED, 02, 03, 04, 05, 06, 07
- **number** (required) `string`: 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 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** (required) `string`: Full address (street, number, floor, etc.) - Latin characters only (example: "Calle Mayor, 123")
- **number** `string`: Street number. Optional: omit it when the address has none, or when `street` already carries the address in full. (example: "123")
- **floor** `string`: Floor or level (example: "2º A")
- **door** `string`: Door or apartment (example: "A")
- **postal_code** (required) `string`: Postal code (5 digits for Spain, free format for other countries) (example: "28001")
- **city** (required) `string`: City or town - Latin characters only (example: "Madrid")
- **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`. (example: "Madrid")
- **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`). (example: "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`. (example: "ES")

## PhoneInput

A phone number as this API accepts it: 9 to 20 characters, and only digits, spaces,
dashes, parentheses and an optional leading `+`. Every request that takes a phone number
uses this schema.

It is `Phone` plus the rules enforced on input. A value this schema accepts always
satisfies `Phone`, so anything you send here is something a response can return.

Type: `string`

## Email

Email address (minimum valid email is 5 chars, e.g. a@b.co)

Type: `string` (email)

## PaymentInfo

- **method**: Preferred payment method. Omitted, `BANK_TRANSFER` applies. If NONE is selected, no payment information will be shown on the invoice.
- **iban** `IBAN`: IBAN (International Bank Account Number). Required when payment method is BANK_TRANSFER.
- **swift** `SWIFT`: SWIFT/BIC code
- **payment_term_days** `integer`: 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. (example: 30)

## CustomerValidationMetadata

Metadata about the validation operation.
Compatible with bulk JSON, CSV import and Holded Excel import.

- **total_customers** (required) `integer`: Total number of customers submitted (example: 10)
- **is_dry_run** (required) `boolean`: Whether it was validation (true) or import (false) (example: true)
- **processing_time_ms** (required) `integer`: Processing time in milliseconds (example: 450)
- **source_type** (required) `string`: 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 — one of: BULK_JSON, CSV_IMPORT, HOLDED_EXCEL (example: "CSV_IMPORT")
- **filename** `string`: File name (CSV or Excel depending on source_type) (example: "clientes_enero.csv")
- **file_size_bytes** `integer`: File size in bytes (example: 1048576)
- **total_rows** `integer`: Total file rows excluding headers (example: 250)

## CustomerValidationItem

Individual customer validation result.
Works for bulk JSON, CSV import and Holded Excel import.

- **index** (required) `integer`: Customer index in the submitted list (0-based for bulk, 1-based for files). For CSV/Excel, represents the row number in the file. (example: 0)
- **customer**: **Echo of what was submitted**, parsed and validated. Absent when the row could not be parsed at all. It is deliberately NOT a `Customer`: the same operation also runs as a rehearsal (`dry_run=true`), where nothing is written and there is no `id` nor `created_at` to report. Typing it as `Customer` — which requires both — made the contract impossible to honour in one of its two modes, and it was in fact honoured in neither. **The identifier of what was created travels in `customer_id`**, at row level, and only when the row actually created something.
- **customer_id**: Identifier of the customer this row created. **Present only when the row actually created something**: absent in a rehearsal (`dry_run=true`), absent on a row that was rejected, and absent on a batch that was rejected as a whole. After a `201`, every row carries it, and `GET /v1/companies/{company_id}/customers/{customer_id}` answers `200`.
- **status** (required) `string`: 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 — one of: VALID, WARNING, ERROR, DUPLICATE, NIF_INVALID (example: "VALID")
- **errors** (required) `array[CustomerValidationError]`: List of errors found (block import)
- **warnings** (required) `array[CustomerValidationWarning]`: List of warnings (do not block import)
- **row_number** `integer`: Row number in the file (CSV or Excel depending on source_type) (example: 15)

## CustomerValidationStatistics

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** (required) `integer`: Total customers processed (example: 10)
- **valid** (required) `integer`: Completely valid customers (no warnings or errors) (example: 6)
- **with_warnings** (required) `integer`: Valid customers but with warnings (example: 2)
- **with_errors** (required) `integer`: Customers with errors (not importable) (example: 2)
- **duplicates** (required) `integer`: Customers with duplicate NIFs (example: 1)
- **invalid_nifs** (required) `integer`: Customers with NIFs not valid in AEAT (example: 1)
- **imported** (required) `integer`: 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`). (example: 8)
- **success_rate** (required) `number` (float): Success rate (valid + with_warnings) / total_processed (example: 0.8)
- **importable** `integer`: Total customers that can be imported (valid + with_warnings) (example: 8)
- **not_importable** `integer`: Total customers that CANNOT be imported (with_errors + duplicates + invalid_nifs) (example: 2)

## IBAN

IBAN (International Bank Account Number).
Required when payment method is BANK_TRANSFER.

Type: `string`

## SWIFT

SWIFT/BIC code

Type: `string`

## CustomerValidationError

Field-specific validation error

- **field** (required) `string`: Field that failed validation (example: "nif")
- **value** (required) `string`: Value that caused the error (example: "12345678X")
- **message** (required) `string`: 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. (example: "NIF not found in AEAT census")
- **code** (required) `string`: 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`. (example: "CUSTOMER_IDENTIFIER_REQUIRED")

## CustomerValidationWarning

Validation warning (does not block import)

- **field** (required) `string`: Field with warning (example: "email")
- **message** (required) `string`: Warning message (example: "Email not provided, NIF will be used for invoicing")


---

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