---
title: "Delete several customers"
method: DELETE
path: "/v1/customers/bulk"
tags: ["Customer Import"]
deprecated: true
---

# Delete several customers

`DELETE /v1/customers/bulk`

## Request

- Base URL: `https://app.beel.es/api`
- URL: `https://app.beel.es/api/v1/customers/bulk`
- Auth: HTTP bearer

> **Deprecated.**

Deletes the customers listed in `ids`.

- **Deprecated:** use `DELETE /v1/companies/{company_id}/customers/bulk`, which behaves
  identically.

## Partial results

- **Partial:** the customers that can be deleted are deleted, and the rest keep their
  place in `customers_deletion` with the status that explains why.
- **`HAS_INVOICES`:** a customer that has invoices cannot be deleted and comes back with
  that row status.

## What deleting means

- **Semantics:** each deletion behaves as `DELETE /v1/customers/{customer_id}`. The
  customer is retained for tax record-keeping purposes but is no longer exposed by the
  API, its identifier is released for reuse, and invoices already issued to it keep their
  own copy of the recipient's details.
- **Not deactivating:** deleting frees the identifier, so the same NIF can be registered
  again, while `PATCH` with `active: false` leaves the customer where it is with its NIF
  still taken.

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

## Query parameters

- `ids` string, required

## Response `200`

Request processed. Always `200` when the batch was processed, **even if not a single
customer could be deleted**: the status code says whether the request was understood
and run, not whether every row went well. The per-row split is in the report.

- object: The envelope every successful JSON response of the BeeL. API is wrapped in. There are no bare resources in v1 and none are planned: the payload always hangs off `data`. - A **single resource** is an object in `data`. - A **collection** hangs off a named key inside `data`, together with its `pagination`, also inside `data` — `data: {invoices: [...], pagination: {...}}`. - A collection carries `pagination` unless its operation declares itself a **closed catalogue**: a fixed, bounded list with nothing to page through. The declaration is explicit in the operation; a missing `pagination` is never something to infer. - `GET /v1/accounts` pages by cursor (`data: {accounts: [...], next_cursor}`). It is a documented variant of pagination, not another envelope. Putting the array straight into `data` with `pagination` as its sibling is the shape a v2 would adopt; v1 is not being flipped to it.
  - `success` boolean, required
  - `data` object, required: Report of a bulk delete of customers, in the same shape every batch operation of this API uses (ADR-0011): `metadata` (what the operation was), **one entry per identifier submitted** in its original position and never filtered, and `statistics` derived by counting those entries. **This endpoint deletes, it does not deactivate.** A deleted customer is retained internally for tax record-keeping but is no longer exposed by the API, and **its identifier is released for reuse** (the unique indexes only cover rows that are not deleted). Deactivating is the opposite: the customer stays where it is, marked inactive, with its NIF still taken. A customer that has invoices can never be deleted — it can only be deactivated — which is why it gets its own row status (`HAS_INVOICES`) instead of being reported as a failure. **HTTP status:** always `200` when the request was processed, even if no row could be deleted. The code says whether the request was processed, not whether every row went well; the split is in this report.
    - `metadata` CustomerBulkDeleteMetadata, required: What the operation was, and whether it was a rehearsal.
      - `total_customers` integer, required: Number of identifiers submitted.
      - `operation` 'DELETE_CUSTOMERS', required: The batch operation this report belongs to.
      - `is_dry_run` boolean, required: Always `false`. Deleting has no rehearsal mode; the field is here so every batch report of this API has the same shape.
    - `customers_deletion` CustomerBulkDeleteItem[], required: One entry per identifier submitted, in the order they were sent and never filtered: a customer that was not deleted keeps its place and its `index`, so the response lines up with the request.
      - `index` integer, required: Position of the identifier in the submitted list (0-based).
      - `customer_id` string, uuid, required: Universally Unique Identifier (UUID v4)
      - `status` 'DELETED' | 'NOT_FOUND' | 'HAS_INVOICES' | 'HAS_RECURRING_INVOICE' | 'ERROR', required: Outcome of this row, in the vocabulary of deleting (deleting validates nothing beforehand, so it does not speak of `VALID`): - `DELETED`: deleted; its identifier is free to be used again - `NOT_FOUND`: no such customer, or the current company/grant cannot see it - `HAS_INVOICES`: it has invoices, so it cannot be deleted — **deactivate it instead** (`PATCH` with `active: false`). This is a business rule, not a failure: retrying the delete will never work - `HAS_RECURRING_INVOICE`: an active or paused recurring invoice template uses it as its customer, so it cannot be deleted — finish the template or change its customer first. This is a business rule, not a failure: retrying the delete will never work - `ERROR`: an unexpected failure while processing this row
      - `error` CustomerBulkDeleteError: Why a row was not deleted.
        - `code` 'NOT_FOUND' | 'CLIENT_HAS_INVOICES' | 'CLIENT_HAS_RECURRING_INVOICE' | 'INTERNAL_ERROR', required: Stable, uppercase, never translated — this is what an integration compares. `NOT_FOUND` means the id does not resolve to a customer of the company, and `INTERNAL_ERROR` that the row failed for an unexpected reason and was not deleted. `CLIENT_HAS_INVOICES` is deliberately the same code the single `DELETE` answers with `409`, so the single and the bulk route never disagree about the same customer. `CLIENT_HAS_RECURRING_INVOICE`: an active or paused recurring invoice uses the customer as its recipient. The single `DELETE` rejects the same case with `400` `REFERENCED_BY_RECURRING_INVOICE`.
        - `message` string, required: Human-readable text, already resolved to the language of the request.
    - `statistics` CustomerBulkDeleteStatistics, required: Counters derived by counting the entries of `customers_deletion`, never accumulated while processing. Each row falls into exactly one of `deleted`, `not_found`, `has_invoices`, `has_recurring_invoice` and `errors`, so those five add up to `total_processed`.
      - `total_processed` integer, required: Identifiers submitted.
      - `deleted` integer, required: Customers deleted (their identifiers are free again).
      - `not_found` integer, required: Identifiers that do not exist, or that the current company/grant cannot see.
      - `has_invoices` integer, required: Customers that have invoices and therefore cannot be deleted. Not a failure: deactivate them instead.
      - `has_recurring_invoice` integer, required: Customers with an active or paused recurring invoice template using them as its customer, and therefore cannot be deleted. Not a failure: finish the template or change its customer instead.
      - `errors` integer, required: Rows that failed for an unexpected reason.
    - `deleted_ids` UUID[], required: IDs of the customers that were deleted.
    - `deactivated_ids` UUID[], required: **Deprecated alias of `deleted_ids`** — identical content. The name was wrong: this operation deletes (freeing the identifier), it does not deactivate (which keeps it taken). Read `deleted_ids`. It will be removed no earlier than the `Sunset` date announced for the deprecated flat routes.
    - `total` integer, required: **Deprecated.** Same as `statistics.total_processed`.
    - `successful` integer, required: **Deprecated.** Same as `statistics.deleted`.
    - `failed` integer, required: **Deprecated.** Number of submitted identifiers that were NOT deleted, for whatever reason. It lumps together a customer that does not exist and one that has invoices and never can be deleted — which is exactly why the per-row `status` exists. Read `statistics`.
    - `errors` CustomerBulkDeleteLegacyError[]: **Deprecated.** Projection of the entries of `customers_deletion` that were not deleted, in the same order. Read `customers_deletion`, which also reports the rows that worked.
      - `customer_id` string, uuid, required: Universally Unique Identifier (UUID v4)
      - `error` CustomerBulkDeleteError, required: Why a row was not deleted.
        - `code` 'NOT_FOUND' | 'CLIENT_HAS_INVOICES' | 'CLIENT_HAS_RECURRING_INVOICE' | 'INTERNAL_ERROR', required: Stable, uppercase, never translated — this is what an integration compares. `NOT_FOUND` means the id does not resolve to a customer of the company, and `INTERNAL_ERROR` that the row failed for an unexpected reason and was not deleted. `CLIENT_HAS_INVOICES` is deliberately the same code the single `DELETE` answers with `409`, so the single and the bulk route never disagree about the same customer. `CLIENT_HAS_RECURRING_INVOICE`: an active or paused recurring invoice uses the customer as its recipient. The single `DELETE` rejects the same case with `400` `REFERENCED_BY_RECURRING_INVOICE`.
        - `message` string, required: Human-readable text, already resolved to the language of the request.
  - `meta` ResponseMeta
    - `timestamp` string, date-time
    - `request_id` string

## Other responses

- `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.
- `401`: Missing or invalid authentication. Like every other error, `message`/`detail` is localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English when the header is missing or asks for none of those.
- `403`: 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.
- `429`: Rate limit exceeded. `RATE_LIMIT_EXCEEDED` when the request rate of your credential is over its limit; `TOO_MANY_REQUESTS` when an email the operation sends is held back because too many went out to that recipient in a short time. Either way, retry after the `Retry-After` seconds. `QUOTA_EXCEEDED` is the other `429`: the operation would go over a quota of your plan for the current period (issued invoices, for instance). Retrying does not help until the quota resets or the plan changes. `error.details` carries `feature_code`, `current_usage`, `quota_limit` and `reset_date`, and `RateLimit-Reset` the seconds until the reset when it is known.
- `500`: Internal server error
- `default`: Any status code the operation does not list above. Every operation declares it, so a generated client always has a branch to fall into and never loses the cause of a failure it did not anticipate. This is where the transport-level answers land — `405`, `406`, `415` and `429` — together with any status a future version of the API starts returning. All of them carry the same error envelope as the codes listed explicitly, so `error.code` is what tells them apart: switching on the status code alone is not enough. See «Transport-level errors» in the API description for when each one is produced. A `402` carrying `SUBSCRIPTION_REQUIRED` lands here when the account has no active subscription. `error.details` says why and what to do: `current_status` (absent if it never had one), `required_permission` and `action`. A `503` carrying `SERVICE_UNAVAILABLE` lands here too, with a `Retry-After` header: a dependency of BeeL itself (the database, the email provider) was momentarily unavailable and nothing was done. Retry after those seconds. A `502` carrying `EXTERNAL_SERVICE_ERROR` also lands here: an outbound integration the operation depends on failed or did not answer in time. It is a transient condition — retry with the same `Idempotency-Key` where the operation accepts one. One exception to the envelope: a failure of the network edge that never reaches the application (`502`, `503`, `504`, `524`) is generated by Cloudflare and its body is not BeeL's — it may not even be JSON. Treat those as "no answer", and retry.

---

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