# Import managed accounts from a file API Reference

Provisions the managed accounts described in an uploaded file, switches each one on in
Live, and leaves them ready to invoice. It is the same act as calling `POST /v1/accounts`
once per row and then `POST /v1/companies/{company_id}/activations`, with the bookkeeping
done for you.

## Idempotency and re-runs

- **Not atomic:** each row is processed and reported independently, and a row that fails
  leaves the rows already provisioned in place. `statistics.accounts_created` is how many
  accounts this call actually created.
- **Declarative and re-runnable:** each pass applies only what is missing — an
  `external_ref` you already provisioned is reconciled, not duplicated, and so are its
  series and its customers. That is the recovery path for anything that went wrong: fix the
  cause and upload the same file again; there is no resume and no partial state to clean
  up.
- **`Idempotency-Key`:** required, but the real guarantee is in the data. Rows are
  idempotent by `external_ref`, so the same file uploaded twice creates nothing twice even
  under a different key.
- **Dry run:** to see what this would do without writing anything, use
  `POST /v1/accounts/imports/preview`, a separate operation with no effects at all — the
  import is never governed by a boolean flag.

## Files and limits

- **`accounts_file`:** describes the accounts, one per row.
- **`customers_file`:** optional, and holds a list of customers applied to **every** account
  of the import, new and pre-existing alike, so a new managed account is born knowing all
  the customers and a new customer reaches all the accounts on the next pass. It is the
  same CSV that `GET /v1/templates/customer-import` describes, and it is idempotent by tax
  id.
- **`options.apply_customers_to_own_company`:** lands those customers on your own company
  as well — the one in focus, never one chosen for you. That outcome comes back apart, in
  `own_company_customers`, and stays out of `statistics.customers_created`.
- **Limits:** 5 MB per file, 100 rows in the accounts file and 1,000 in the customers file.
  A larger population is imported in passes, which costs nothing because the file is
  declarative.

## Live activation

Live activation is part of the act: every row is weighed against the same verdict the
account state publishes, and only rows entitled to Live are executed; the rest come back
`BLOCKED` with the reason. The import never opens a checkout, so it never charges you by
surprise: settle your billing once and re-upload.

## Claim tokens

`account.claim_token` and `account.claim_url`: each newly provisioned row carries them
in this response and nowhere else, so persist them before discarding it. A lost token is
re-issued with `POST /v1/accounts/{account_id}/claim-tokens`.

Re-uploading the file never breaks the links you already handed out: a row whose account is
still unclaimed and holds a valid claim link comes back with `claim_token` and `claim_url`
`null` and `account.claim_link_already_issued: true`. Only an account with no valid link left
(expired, or never issued) gets a fresh one.


## POST /v1/accounts/imports

**Import managed accounts from a file**

Provisions the managed accounts described in an uploaded file, switches each one on in
Live, and leaves them ready to invoice. It is the same act as calling `POST /v1/accounts`
once per row and then `POST /v1/companies/{company_id}/activations`, with the bookkeeping
done for you.

## Idempotency and re-runs

- **Not atomic:** each row is processed and reported independently, and a row that fails
  leaves the rows already provisioned in place. `statistics.accounts_created` is how many
  accounts this call actually created.
- **Declarative and re-runnable:** each pass applies only what is missing — an
  `external_ref` you already provisioned is reconciled, not duplicated, and so are its
  series and its customers. That is the recovery path for anything that went wrong: fix the
  cause and upload the same file again; there is no resume and no partial state to clean
  up.
- **`Idempotency-Key`:** required, but the real guarantee is in the data. Rows are
  idempotent by `external_ref`, so the same file uploaded twice creates nothing twice even
  under a different key.
- **Dry run:** to see what this would do without writing anything, use
  `POST /v1/accounts/imports/preview`, a separate operation with no effects at all — the
  import is never governed by a boolean flag.

## Files and limits

- **`accounts_file`:** describes the accounts, one per row.
- **`customers_file`:** optional, and holds a list of customers applied to **every** account
  of the import, new and pre-existing alike, so a new managed account is born knowing all
  the customers and a new customer reaches all the accounts on the next pass. It is the
  same CSV that `GET /v1/templates/customer-import` describes, and it is idempotent by tax
  id.
- **`options.apply_customers_to_own_company`:** lands those customers on your own company
  as well — the one in focus, never one chosen for you. That outcome comes back apart, in
  `own_company_customers`, and stays out of `statistics.customers_created`.
- **Limits:** 5 MB per file, 100 rows in the accounts file and 1,000 in the customers file.
  A larger population is imported in passes, which costs nothing because the file is
  declarative.

## Live activation

Live activation is part of the act: every row is weighed against the same verdict the
account state publishes, and only rows entitled to Live are executed; the rest come back
`BLOCKED` with the reason. The import never opens a checkout, so it never charges you by
surprise: settle your billing once and re-upload.

## Claim tokens

`account.claim_token` and `account.claim_url`: each newly provisioned row carries them
in this response and nowhere else, so persist them before discarding it. A lost token is
re-issued with `POST /v1/accounts/{account_id}/claim-tokens`.

Re-uploading the file never breaks the links you already handed out: a row whose account is
still unclaimed and holds a valid claim link comes back with `claim_token` and `claim_url`
`null` and `account.claim_link_already_issued: true`. Only an account with no valid link left
(expired, or never issued) gets a fresh one.

### Authentication

Accepts any of:

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

### Parameters

- **Idempotency-Key** (required) in header `string`: Same key as `Idempotency-Key` above, but **required**: the operation writes many rows per call, so a retry without a key would import the same file twice. A missing key answers `400 IDEMPOTENCY_KEY_REQUIRED`.

### Request Body

Required.

**Content `multipart/form-data`:**

- **accounts_file** (required) `string` (binary): CSV with one managed account per row, in the format of `GET /v1/templates/account-import`: UTF-8, header names matched case-insensitively, and `,`, `;` or tab accepted as separator. The header row does not have to be the first line. A spreadsheet almost never starts on it — there is a title, sometimes a blank line — and that preamble travels ahead of the data when the sheet is exported. Everything before the first line carrying the required columns is ignored, within the first 20 lines. Reported `row_number`s still count from the top of the file, so they match what you see when you open it.
- **customers_file** `string` (binary): Optional CSV of customers to apply to **every** account of this import, in the format of `GET /v1/templates/customer-import`. Idempotent by tax id. Its preamble is skipped the same way — it comes out of the same spreadsheet.
- **options** `AccountImportOptions`: Settings that apply to **every row** of the file. They live here and not as columns because an agency onboards all of its managed accounts the same way: a column nobody varies is a column everybody mistypes. Travels as an `application/json` part named `options` inside the multipart body — send it with its own `Content-Type: application/json` (`-F 'options=…;type=application/json'` in curl). Omit the part entirely to take the defaults.

### Responses

#### 201: The import ran, in the same format as the preview. The status is fixed: it reports that the import ran, not that any particular row succeeded. `metadata.is_dry_run` is `false`, and the write counters in `statistics` are what actually reached the database — they can all be `0` when every row was already there or blocked.

**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** (required) `AccountImportResult`: The outcome of the file, row by row, in one shape for the preview and for the import. Which one produced it is `metadata.is_dry_run`; what it actually wrote is the write counters in `statistics`.

#### 400: The file could not be read: `EMPTY_FILE`, `INVALID_FORMAT`, `ENCODING_ERROR`, or `PARSING_ERROR` — which also covers a file with no header row in its first 20 lines, naming the columns it looked for. Nothing was written.

**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")

#### 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"
  }
}
```

#### 402: `PAYMENT_REQUIRED` — your own billing is past due, so nothing can be provisioned on your behalf until the outstanding invoice is settled. Decided before the file is read: no row is reported.

**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")

#### 403: `FEATURE_NOT_AVAILABLE` — your account does not hold the managed-accounts capability. A credential without the `accounts:write` scope answers the same way.

With `options.apply_customers_to_own_company` there are two more, both decided before the files are read: `ACTIVE_COMPANY_REQUIRED`, when your account holds several NIFs and none is in focus — the option writes on your own company and this endpoint never picks one for you — and `INSUFFICIENT_SCOPE`, when the API key lacks `customers:write`.

**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")

#### 409: `IDEMPOTENCY_KEY_MISMATCH` — this key was already used for a different request. Use a new key, or resend the original request to replay its cached response.

**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")

#### 413: Payload too large. On this operation that normally means an uploaded file
exceeded its own limit (`FILE_TOO_LARGE`). Any request may also be
rejected with `REQUEST_BODY_TOO_LARGE` when the body as a whole exceeds
the maximum described under *Request body size*. In both cases the
`details` object carries the applicable maximum.


**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**: No description

**Example:**

```json
{
  "success": false,
  "error": {
    "code": "FILE_TOO_LARGE",
    "message": "The file exceeds the maximum allowed size of 5.0 MB.",
    "details": {
      "max_size_bytes": "5242880",
      "max_size_formatted": "5.0 MB"
    }
  },
  "meta": {
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

#### 422: `TOO_MANY_RECORDS` — a file carries more rows than its limit. The whole file is rejected and no row is reported: an import of this weight is answered synchronously, so the cap is what keeps the request from timing out half-done.

`MISSING_REQUIRED_FIELD` naming `customers_file` — `options.apply_customers_to_own_company` was sent without the customers file it applies. Ignoring the option instead would answer `201` to a request that did none of what it asked for.

**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")

#### 429: Rate limit exceeded

**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 `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

## AccountImportUpload

- **accounts_file** (required) `string` (binary): CSV with one managed account per row, in the format of `GET /v1/templates/account-import`: UTF-8, header names matched case-insensitively, and `,`, `;` or tab accepted as separator. The header row does not have to be the first line. A spreadsheet almost never starts on it — there is a title, sometimes a blank line — and that preamble travels ahead of the data when the sheet is exported. Everything before the first line carrying the required columns is ignored, within the first 20 lines. Reported `row_number`s still count from the top of the file, so they match what you see when you open it.
- **customers_file** `string` (binary): Optional CSV of customers to apply to **every** account of this import, in the format of `GET /v1/templates/customer-import`. Idempotent by tax id. Its preamble is skipped the same way — it comes out of the same spreadsheet.
- **options** `AccountImportOptions`: Settings that apply to **every row** of the file. They live here and not as columns because an agency onboards all of its managed accounts the same way: a column nobody varies is a column everybody mistypes. Travels as an `application/json` part named `options` inside the multipart body — send it with its own `Content-Type: application/json` (`-F 'options=…;type=application/json'` in curl). Omit the part entirely to take the defaults.

## AccountImportOptions

Settings that apply to **every row** of the file. They live here and not as columns because an agency onboards all of its managed accounts the same way: a column nobody varies is a column everybody mistypes.

Travels as an `application/json` part named `options` inside the multipart body — send it with its own `Content-Type: application/json` (`-F 'options=…;type=application/json'` in curl). Omit the part entirely to take the defaults.

- **access_level**: The access you keep over every account of this file. Defaults to `OPERATE` — not to `NONE` as in `POST /v1/accounts`, and the difference is deliberate: a file-driven import is re-run to reconcile what is missing, and reconciling an account's series and customers is a write that `VIEW` cannot do. An account you already manage with less than this is reported `BLOCKED` and skipped: once its holder has claimed it, only they can raise your access again.
- **series** `array[CreateSeriesRequest]`: Invoice series every account must end up with, declared once. Each entry is a `CreateSeriesRequest` exactly as `POST /v1/companies/{company_id}/series` takes it, with one addition: the token `{REF}` in `name` and `format` is replaced with the row's `external_ref` **before** the series is created, which is how a single declaration numbers every account distinguishably. `{REF}` is vocabulary of this endpoint only — what reaches the series is a literal format, so nothing downstream has to learn a new variable. A row whose `external_ref` would produce a format outside the series grammar (uppercase letters, digits, `-`, `_`, `/`, `:`) is reported and its series left alone. Leave it out and each account keeps the default series its activation seeds.
- **apply_customers_to_own_company** `boolean`: Load the shared customers file into **your own** company too, not only into the managed accounts the file describes. Off by default: an import of accounts writes on the accounts it imports, and a file that quietly also wrote on your own customers would be a surprise nobody asked for. **Your own company is the one in focus for this request** — the same one every company-scoped write of the API uses, and never one this endpoint picks for you. With several NIFs and none in focus the whole import is refused with `403` `ACTIVE_COMPANY_REQUIRED`, decided **before the files are read**: no account is provisioned and nothing is billed. Sending the option without `customers_file` is a `422`, decided in the same place — there is nothing to apply. With an API key it needs `customers:write` on top of the `accounts:write` the operation already requires. It writes customers; a key that may not write customers must not get to through an accounts import. A dashboard session is governed by the role's capabilities over the company in focus, as everywhere else. What it did comes back in `own_company_customers`, apart from the per-account counters.

## 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")

## AccountImportResult

The outcome of the file, row by row, in one shape for the preview and for the import. Which one produced it is `metadata.is_dry_run`; what it actually wrote is the write counters in `statistics`.

- **metadata** (required) `AccountImportMetadata`
- **accounts_validation** (required) `array[AccountImportItem]`: One entry per data row of the accounts file, in file order and **never filtered**: a row that failed keeps its place and its `row_number`, so the answer lines up with the spreadsheet.
- **customers_source**: The optional customers file, checked **once** for the whole import. Absent when none was sent. What each account did with it is on its own row.
- **own_company_customers**: What the shared customers file did on **your own** company, when `options.apply_customers_to_own_company` asked for it. Absent otherwise. It sits at this level and not among the rows because your company is not a row of the accounts file: it is not provisioned, not activated and not given series — only its customers are seeded. Same shape as the per-account outcome, with one difference that `metadata.is_dry_run` settles: in a preview `created` is what **would** be created, since nothing was written; in an import it is what reached the database. `already_existed` is what the company already knew either way — which is what makes re-uploading the file cheap here too. **It is deliberately not added to `statistics.customers_created`.** That counter is about the accounts you imported, and folding your own copy into it would inflate the number you reconcile your onboarding against. Your own company also **fails like a row**: if seeding it fails, the managed accounts already seeded stay exactly as they are and the failure is reported here, with its rows counted in `failed`.
- **statistics** (required) `AccountImportStatistics`: Aggregated outcome. Every field except the write counters classifies rows of the accounts file: each row falls into exactly one of `valid`, `with_warnings`, `already_existed`, `blocked` and `with_errors`, so those five add up to `total_rows`. The write counters — `accounts_created`, `live_activations_created`, `series_created`, `customers_created` — are the only fields that count writes, so they are what to read to know whether an import worked. They are all `0` in a preview.

## 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"})

## CreateSeriesRequest

Request to create an invoice series. `document_type` is required: a new series always has
a type, and `UNASSIGNED` is rejected with `422 SERIES_UNASSIGNED_TYPE_NOT_ALLOWED`.
Corrective invoices need a series of their own (`CORRECTIVE`).

- **document_type** (required) `DocumentType`: Document type associated with a series. Values mirror `InvoiceType`, so the series a document needs is named exactly like the document: - UNASSIGNED: Legacy value of series created before types existed. A series numbers only documents of its own type, so an `UNASSIGNED` series numbers none (`422 SERIES_INCOMPATIBLE_DOC_TYPE`); give it a type to keep using it. No series can be created with it or moved to it (`422 SERIES_UNASSIGNED_TYPE_NOT_ALLOWED`). The live ones were given the type they numbered most. - STANDARD: Standard invoice - SIMPLIFIED: Simplified invoice - CORRECTIVE: Corrects or cancels a previous invoice - PROFORMA: Proforma (commercial document, non-fiscal numbering)
- **name** (required) `string`: Descriptive name of the series (example: "Main Series")
- **code** (required) `SeriesCode`: Alphanumeric series code (used in {CODIGO} variable). Allows uppercase letters, numbers, hyphens and underscores.
- **description** `string`: Optional series description (example: "Series for standard invoices")
- **format** (required) `SeriesFormat`: Format template with available variables (UPPERCASE ONLY): - {CODIGO}: Series code (e.g., "FAC") - {YYYY}: Year with 4 digits (e.g., "2025") - {YY}: Year with 2 digits (e.g., "25") - {MM}: Month with 2 digits (e.g., "01") - {NUM}: Sequential number without padding (e.g., "1") - {NUM:X}: Sequential number with padding (e.g., {NUM:4} → "0001") **REQUIRED**: Must contain at least {NUM} or {NUM:X} **IMPORTANT**: Only uppercase (rejects {yy}, {mm}, {codigo}, etc.) Valid examples: - "{CODIGO}-{YYYY}-{NUM:4}" → "FAC-2025-0001" - "{CODIGO}/{NUM:6}" → "FAC/000001" - "{YYYY}{MM}-{NUM:3}" → "202501-001" The generated number is the invoice number sent to the AEAT, which accepts at most 60 printable ASCII characters and none of `"`, `'`, `<`, `>`, `=`. A format whose longest possible number breaks that rule is rejected with `422 SERIES_FORMAT_NUMBER_TOO_LONG` or `SERIES_FORMAT_INVALID_CHARACTERS`. The counter counts as at least 9 digits, with or without padding: `{NUM:X}` is a minimum width, not a maximum.
- **counter_reset**: When this series' counter resets. Defaults to `ANNUAL` when omitted — so a format without a year token must come with `counter_reset: NEVER`.
- **initial_number** `integer` (int64): Initial number for this series counter. Useful when migrating from another system and wanting to continue existing numbering. For example, if the last invoices were 2024-0150, you can set initial_number=151. Default value is 1. It applies only to the first period in which the series issues an invoice: with `counter_reset: ANNUAL` or `MONTHLY`, every later year or month starts at 1. With `NEVER` there is a single period, so numbering simply continues from it. (example: 1)
- **active** `boolean`: Whether the series is active
- **default_series** `boolean`: Whether this is the default series for its document_type. **Auto-promotion:** the domain guarantees that, while at least one series exists for a given `(document_type, environment)`, exactly one of them is the default. So if you create the **first** series of a `document_type` (no default exists yet for that type and environment), it is marked as default **even if you send `false`** — the response will then return `default_series: true`. Send `true` to also unmark the current default of that type.

## AccountImportMetadata

- **is_dry_run** (required) `boolean`: `true` for the preview (nothing was written), `false` for the import. It is what tells the two answers apart when they are stored side by side.
- **total_rows** (required) `integer`: Data rows found in the accounts file, header excluded. (example: 19)
- **processing_time_ms** (required) `integer` (int64): No description (example: 18400)
- **accounts_filename** `string`: Name of the uploaded accounts file, as your client sent it.
- **customers_filename** `string`: Name of the uploaded customers file; `null` when none was sent.
- **environment** (required): The mode this import ran in, taken from the credential. Restated because it decides whether the NIFs were switched on against the real AEAT and whether anything was billed.

## AccountImportItem

One row of the accounts file and everything that happened to it.

- **row_number** (required) `integer`: Row number **as the spreadsheet counts it**: the header is row 1, so the first account is row 2. Not an index — the point is that you can open the file and go to that line. (example: 2)
- **external_ref** `string`: Your own id for the account; `null` when the row was too malformed to read it.
- **nif** `string`: The row's tax id, normalised; `null` when it could not be read.
- **status** (required) `AccountImportItemStatus`: Outcome of one row. The same five values are used by the preview and by the import — the preview reports what the import would reach — so no value tells you which operation produced the payload; `metadata.is_dry_run` does. * `VALID` — complete and reachable. Provisioned by the import; would be by a preview. * `WARNING` — the same, with non-blocking warnings. * `ALREADY_EXISTS` — an account with this `external_ref` is already yours. A documented success, not a collision: provisioning is idempotent by `external_ref`, so nothing is created and nothing is billed again. On an import the row is still reconciled (Live, series, customers), which is what makes re-uploading the same file useful. * `BLOCKED` — well formed but not executable. `errors` says why, and it is always something to resolve outside the file. * `ERROR` — the row's own data is wrong. `errors` says which column.
- **live_activation_verdict**: Whether this row's NIF may be switched on in Live, decided exactly as `POST /v1/companies/{company_id}/activations` decides it. Anything other than `ENTITLED` blocks the row: the import never opens a checkout on your behalf. Absent when the row was rejected before reaching that question.
- **account**: The account this row produced — the same shape `POST /v1/accounts` returns, carrying `account_id`, `company_id` and the single-use `claim_token` / `claim_url` to hand to the holder. Absent in a preview (nothing was provisioned) and on a `BLOCKED` or `ERROR` row. On `ALREADY_EXISTS` it is the account that was already there, and a claim link still valid is kept: `claim_token` is `null` and `claim_link_already_issued` is `true`. **The token is shown once**: store it, or re-issue it with `POST /v1/accounts/{account_id}/claim-tokens`.
- **series** (required) `array[AccountImportSeriesOutcome]`: What each declared series did on this account. Empty when the import declares none.
- **customers**: How the shared customers file landed on **this** account. Absent in a preview, and when no customers file was sent: whether a customer is new depends on the account, and that only shows up when the import runs.
- **errors** (required) `array[AccountImportIssue]`: What stops this row. A row with errors is never executed.
- **warnings** (required) `array[AccountImportIssue]`: What you should know even though the row goes through.

## AccountImportStatistics

Aggregated outcome. Every field except the write counters classifies rows of the accounts file: each row falls into exactly one of `valid`, `with_warnings`, `already_existed`, `blocked` and `with_errors`, so those five add up to `total_rows`.

The write counters — `accounts_created`, `live_activations_created`, `series_created`, `customers_created` — are the only fields that count writes, so they are what to read to know whether an import worked. They are all `0` in a preview.

- **total_rows** (required) `integer`: No description (example: 19)
- **valid** (required) `integer`: No description (example: 11)
- **with_warnings** (required) `integer`: No description (example: 1)
- **already_existed** (required) `integer`: No description (example: 6)
- **blocked** (required) `integer`: No description (example: 1)
- **with_errors** (required) `integer`: No description (example: 0)
- **importable** (required) `integer`: `valid + with_warnings` — rows a real import would provision. (example: 12)
- **accounts_created** (required) `integer`: Accounts actually provisioned by this request. Always `0` in a preview. (example: 12)
- **live_activations_created** (required) `integer`: NIFs actually switched on by this request. In Live each one adds an item to your subscription, so this is what was billed. Always `0` in a preview. (example: 12)
- **live_activations_pending** (required) `integer`: NIFs a real import **would** switch on. In a preview this is the billing forecast — read it before importing; in an import it is what is left, normally `0`. (example: 0)
- **series_created** (required) `integer`: No description (example: 24)
- **customers_created** `integer`: Customer rows created across all **managed accounts** of the file. `null` when no customers file was sent, and `0` in a preview. Your own company, when `options.apply_customers_to_own_company` asked for it, is counted apart in `own_company_customers` and never here. (example: 24)

## DocumentType

Document type associated with a series. Values mirror `InvoiceType`,
so the series a document needs is named exactly like the document:
- UNASSIGNED: Legacy value of series created before types existed. A series numbers only
  documents of its own type, so an `UNASSIGNED` series numbers none
  (`422 SERIES_INCOMPATIBLE_DOC_TYPE`); give it a type to keep using it. No series can be
  created with it or moved to it (`422 SERIES_UNASSIGNED_TYPE_NOT_ALLOWED`). The live
  ones were given the type they numbered most.
- STANDARD: Standard invoice
- SIMPLIFIED: Simplified invoice
- CORRECTIVE: Corrects or cancels a previous invoice
- PROFORMA: Proforma (commercial document, non-fiscal numbering)

Type: `string` — one of: UNASSIGNED, STANDARD, SIMPLIFIED, CORRECTIVE, PROFORMA

## SeriesCode

Alphanumeric series code (used in {CODIGO} variable).
Allows uppercase letters, numbers, hyphens and underscores.

Type: `string`

## SeriesFormat

Format template with available variables (UPPERCASE ONLY):
- {CODIGO}: Series code (e.g., "FAC")
- {YYYY}: Year with 4 digits (e.g., "2025")
- {YY}: Year with 2 digits (e.g., "25")
- {MM}: Month with 2 digits (e.g., "01")
- {NUM}: Sequential number without padding (e.g., "1")
- {NUM:X}: Sequential number with padding (e.g., {NUM:4} → "0001")

**REQUIRED**: Must contain at least {NUM} or {NUM:X}
**IMPORTANT**: Only uppercase (rejects {yy}, {mm}, {codigo}, etc.)

Valid examples:
- "{CODIGO}-{YYYY}-{NUM:4}" → "FAC-2025-0001"
- "{CODIGO}/{NUM:6}" → "FAC/000001"
- "{YYYY}{MM}-{NUM:3}" → "202501-001"

The generated number is the invoice number sent to the AEAT, which accepts at most 60
printable ASCII characters and none of `"`, `'`, `<`, `>`, `=`. A format whose longest
possible number breaks that rule is rejected with `422 SERIES_FORMAT_NUMBER_TOO_LONG` or
`SERIES_FORMAT_INVALID_CHARACTERS`. The counter counts as at least 9 digits, with or without
padding: `{NUM:X}` is a minimum width, not a maximum.

Type: `string`

## AccountImportItemStatus

Outcome of one row. The same five values are used by the preview and by the import — the
preview reports what the import would reach — so no value tells you which operation
produced the payload; `metadata.is_dry_run` does.

* `VALID` — complete and reachable. Provisioned by the import; would be by a preview.
* `WARNING` — the same, with non-blocking warnings.
* `ALREADY_EXISTS` — an account with this `external_ref` is already yours. A documented
  success, not a collision: provisioning is idempotent by `external_ref`, so nothing is
  created and nothing is billed again. On an import the row is still reconciled (Live,
  series, customers), which is what makes re-uploading the same file useful.
* `BLOCKED` — well formed but not executable. `errors` says why, and it is always something
  to resolve outside the file.
* `ERROR` — the row's own data is wrong. `errors` says which column.

Type: `string` — one of: VALID, WARNING, ALREADY_EXISTS, BLOCKED, ERROR

## AccountImportSeriesOutcome

What one declared series did on one account.

- **document_type** (required) `DocumentType`: Document type associated with a series. Values mirror `InvoiceType`, so the series a document needs is named exactly like the document: - UNASSIGNED: Legacy value of series created before types existed. A series numbers only documents of its own type, so an `UNASSIGNED` series numbers none (`422 SERIES_INCOMPATIBLE_DOC_TYPE`); give it a type to keep using it. No series can be created with it or moved to it (`422 SERIES_UNASSIGNED_TYPE_NOT_ALLOWED`). The live ones were given the type they numbered most. - STANDARD: Standard invoice - SIMPLIFIED: Simplified invoice - CORRECTIVE: Corrects or cancels a previous invoice - PROFORMA: Proforma (commercial document, non-fiscal numbering)
- **action** (required) `AccountImportSeriesAction`: * `ALREADY_EXISTS` — the account already numbers like this. Nothing was touched. A series counts as present when it numbers identically, whether it is written with `{CODIGO}` or with the code spelled out. * `CREATED` — the series was created (in a preview: would be). * `MANUAL_REVIEW_REQUIRED` — neither the declared code nor its `<code>2` fallback is free, or the resolved format falls outside the series grammar. Inventing a third code would be guessing, and changing the format of a series that has already numbered a document is not something an import may do. Resolve it on the account and re-upload.
- **code** `string`: Code the series was created under — not necessarily the declared one, when that was already taken by a different numbering.
- **format** `string`: Format with `{REF}` already resolved to this row's `external_ref`.

## AccountImportIssue

One thing that happened to a row, with a stable `code` to branch on and a `message` already written in the language of the request. Branch on the code; the message is for people.

- **code** (required) `AccountImportIssueCode`: Machine-readable reason for an issue. New values are added as the import learns to explain more; treat an unknown one as a generic problem rather than failing. Read straight off the row: `REQUIRED_FIELD_MISSING` (the column is there and its **cell** is empty — a missing column is a whole-file error, not a row one), `EXTERNAL_REF_DUPLICATED`, `ENTITY_TYPE_UNKNOWN`, `NIF_INVALID_FORMAT`, `EMAIL_INVALID`, `POSTAL_CODE_INVALID`, `IBAN_INVALID`, `IRPF_NOT_NUMERIC`, `LANGUAGE_UNSUPPORTED`. `IRPF_NOT_ALLOWED` — the rate is a number but not one of `IrpfPercentage`, or not one the row's NIF can bear: the same check as its invoices (see `WithholdingOptions`), so a company (`B…`) declaring 15 % is rejected here. It is an **error**, not a warning: letting it through buys a green preview and an account that fails on its first invoice months later, when nobody connects the two. `POSTAL_CODE_PADDED` — a warning: the postal code was padded with leading zeros, which is almost always the right repair but does change the value, and the first digit picks the province. Census: `NIF_NOT_IN_CENSUS` — the tax id is not in the AEAT register or is no longer active. For an **individual** it also covers a legal name that does not match the one on file; for a **legal entity** the name is not checked at all, because the AEAT identifies a company by its CIF alone and has no answer that means "this name is not the one". State of the account rather than of the file, and the ones that produce `BLOCKED`: `ACCOUNT_ALREADY_EXISTS` (a warning, not a blocker), `ACCESS_LEVEL_INSUFFICIENT`, `LIVE_ACTIVATION_NOT_ENTITLED`, `NIF_ALREADY_LIVE_ELSEWHERE`, `PROVISIONING_EMAIL_ALREADY_REGISTERED`, `PROVISIONING_ACCOUNT_CLAIMED` — each the wire code the equivalent single-account call answers with, so the remedy is the one already documented there. Series: `SERIES_CODE_TAKEN`, `SERIES_MANUAL_RESOLUTION_REQUIRED` (the declared code and its `<code>2` fallback are both taken), `SERIES_CODE_DUPLICATED` (this same import declares the code twice), `SERIES_FORMAT_INVALID` (the format, once `{REF}` is resolved, cannot number — no `{NUM}`, or the `external_ref` added characters the grammar rejects) and `SERIES_DEFAULT_REPLACED`. The first four leave the account **without** that series, and say so. Customers file: `CUSTOMER_ROW_REJECTED` — a row of the shared customers file that will not load anywhere. One issue per offending column, each with the message the customer import wrote for it; a row rejected with no column-level detail carries a single one. The census code above is used here too, but only ever on the `nif` column. Anything else: `UNEXPECTED_ROW_FAILURE`, with the detail in `value`.
- **column** `string`: Column of the file involved; `null` when the issue is about the row as a whole. (example: "direccion_codigo_postal")
- **value** `string`: The offending value, already normalised.
- **message** (required) `string`: Human-readable explanation, in the language of the request. Issues coming from the customers file are the exception: they carry the wording the customer import itself produced, which is Spanish — the same text `POST /v1/companies/{company_id}/customers/imports/preview` returns. Re-labelling them to translate would collapse every error of a row into one repeated sentence, which is worse than one untranslated one.

## AccountImportSeriesAction

* `ALREADY_EXISTS` — the account already numbers like this. Nothing was touched. A series
  counts as present when it numbers identically, whether it is written with `{CODIGO}` or
  with the code spelled out.
* `CREATED` — the series was created (in a preview: would be).
* `MANUAL_REVIEW_REQUIRED` — neither the declared code nor its `<code>2` fallback is free,
  or the resolved format falls outside the series grammar. Inventing a third code would be
  guessing, and changing the format of a series that has already numbered a document is not
  something an import may do. Resolve it on the account and re-upload.

Type: `string` — one of: ALREADY_EXISTS, CREATED, MANUAL_REVIEW_REQUIRED

## AccountImportIssueCode

Machine-readable reason for an issue. New values are added as the import learns to explain
more; treat an unknown one as a generic problem rather than failing.

Read straight off the row: `REQUIRED_FIELD_MISSING` (the column is there and its **cell** is
empty — a missing column is a whole-file error, not a row one), `EXTERNAL_REF_DUPLICATED`,
`ENTITY_TYPE_UNKNOWN`, `NIF_INVALID_FORMAT`, `EMAIL_INVALID`, `POSTAL_CODE_INVALID`,
`IBAN_INVALID`, `IRPF_NOT_NUMERIC`, `LANGUAGE_UNSUPPORTED`.

`IRPF_NOT_ALLOWED` — the rate is a number but not one of `IrpfPercentage`, or not one the
row's NIF can bear: the same check as its invoices (see `WithholdingOptions`), so a
company (`B…`) declaring 15 % is rejected here. It is an **error**, not a warning: letting it through buys a green preview and an
account that fails on its first invoice months later, when nobody connects the two.

`POSTAL_CODE_PADDED` — a warning: the postal code was padded with leading zeros, which is
almost always the right repair but does change the value, and the first digit picks the
province.

Census: `NIF_NOT_IN_CENSUS` — the tax id is not in the AEAT register or is no longer active.
For an **individual** it also covers a legal name that does not match the one on file; for a
**legal entity** the name is not checked at all, because the AEAT identifies a company by its
CIF alone and has no answer that means "this name is not the one".

State of the account rather than of the file, and the ones that produce `BLOCKED`:
`ACCOUNT_ALREADY_EXISTS` (a warning, not a blocker), `ACCESS_LEVEL_INSUFFICIENT`,
`LIVE_ACTIVATION_NOT_ENTITLED`, `NIF_ALREADY_LIVE_ELSEWHERE`,
`PROVISIONING_EMAIL_ALREADY_REGISTERED`, `PROVISIONING_ACCOUNT_CLAIMED` — each the wire code
the equivalent single-account call answers with, so the remedy is the one already documented
there.

Series: `SERIES_CODE_TAKEN`, `SERIES_MANUAL_RESOLUTION_REQUIRED` (the declared code and its
`<code>2` fallback are both taken), `SERIES_CODE_DUPLICATED` (this same import declares the
code twice), `SERIES_FORMAT_INVALID` (the format, once `{REF}` is resolved, cannot number —
no `{NUM}`, or the `external_ref` added characters the grammar rejects) and
`SERIES_DEFAULT_REPLACED`. The first four leave the account **without** that series, and say
so.

Customers file: `CUSTOMER_ROW_REJECTED` — a row of the shared customers file that will not
load anywhere. One issue per offending column, each with the message the customer import
wrote for it; a row rejected with no column-level detail carries a single one. The census
code above is used here too, but only ever on the `nif` column.

Anything else: `UNEXPECTED_ROW_FAILURE`, with the detail in `value`.

Type: `string` — one of: REQUIRED_FIELD_MISSING, EXTERNAL_REF_DUPLICATED, ENTITY_TYPE_UNKNOWN, NIF_INVALID_FORMAT, NIF_NOT_IN_CENSUS, EMAIL_INVALID, POSTAL_CODE_INVALID, POSTAL_CODE_PADDED, IBAN_INVALID, IRPF_NOT_NUMERIC, IRPF_NOT_ALLOWED, LANGUAGE_UNSUPPORTED, ACCOUNT_ALREADY_EXISTS, ACCESS_LEVEL_INSUFFICIENT, LIVE_ACTIVATION_NOT_ENTITLED, NIF_ALREADY_LIVE_ELSEWHERE, PROVISIONING_EMAIL_ALREADY_REGISTERED, PROVISIONING_ACCOUNT_CLAIMED, SERIES_CODE_TAKEN, SERIES_CODE_DUPLICATED, SERIES_FORMAT_INVALID, SERIES_MANUAL_RESOLUTION_REQUIRED, SERIES_DEFAULT_REPLACED, CUSTOMER_ROW_REJECTED, UNEXPECTED_ROW_FAILURE


---

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