# Bulk operations and exports

Issue, mark, download, email and export many invoices at once, and create or delete customers and products in batches — and how to read what came back.

Everything that acts on many resources in one request lives under a sub-resource of the
collection it acts on. Each one returns something different, which is why they are
separate operations:

| You want to… | Operation | Returns |
|---|---|---|
| Issue drafts, or mark invoices sent or paid | [`POST …/invoices/batches`](/invoices/createCompanyInvoiceBatch) | A report per invoice |
| Download many PDFs | [`POST …/invoices/pdf-archive`](/invoices/createCompanyInvoicePdfArchive) | One ZIP |
| Email many invoices in one message | [`POST …/invoices/deliveries`](/invoices/createCompanyInvoiceDelivery) | The result of the send |
| Get a spreadsheet | [`POST …/invoices/exports`](/invoices/createCompanyInvoiceExport) | One `.xlsx` |
| Create customers | [`POST …/customers/bulk`](/customers/createCompanyCustomersBulk) | A report per row |
| Delete customers | [`DELETE …/customers/bulk`](/customers/deleteCompanyCustomersBulk) | A report per row |
| Create products | [`POST …/products/bulk`](/products/createCompanyProductsBulk) | A report per row |
| Delete products | [`DELETE …/products/bulk`](/products/deleteCompanyProductsBulk) | A report of deleted and failed IDs |

All paths hang off `/v1/companies/{company_id}`. Per-request maximums are listed in
[Limits and pagination](/guides/limits-and-pagination#batch-sizes).

## The status code does not summarise the batch

This is the rule to internalise before writing any bulk code:

**The HTTP status says whether the request was processed, not whether every row went well.**

- A **partial** batch processes each row on its own. It answers `200` (or `201` when it
  creates) **even if every single row failed**. The outcome of each row is in the body.
- An **atomic** batch either applies every row or none. It answers `201` when everything
  went in, and a real error — `422` with an error body — when it was rejected.
- A request that is malformed as a whole — a missing field, an empty array, more items
  than the maximum — is rejected with a `4xx` before any row is looked at, whichever kind
  the batch is.

<Callout type="warn">
  **Never treat a `200` or `201` from a partial batch as "all done".** Read the per-row
  report or the counters. A batch where nothing succeeded still returns a success status.
</Callout>

| Operation | Kind |
|---|---|
| Invoice batch (`ISSUE`, `STATUS`) | Partial |
| Invoice delivery | Partial — invoices that cannot be attached are reported and the email still goes out with the rest |
| PDF archive | Partial — missing PDFs are left out of the ZIP |
| Create customers | **Atomic** |
| Delete customers | Partial |
| Create products | Partial |
| Delete products | Partial |

### Reading a per-row report

Customer creation, customer deletion and product creation return the same skeleton inside
`data`:

- `metadata` — which operation it was, and whether it was a dry run.
- **One entry per submitted row**, in the order you sent them and never filtered. A row
  that failed keeps its place and its `index` (0-based), so you can match the report to
  your array position by position.
- `statistics` — counters derived from those rows.

How a row explains a failure depends on the operation:

- **Deleting customers** and **creating products**: the row carries an `error` with a stable,
  uppercase `code` (what you compare, such as `CLIENT_HAS_INVOICES` or `PRODUCT_DUPLICATE`)
  and a `message` already translated to the request language (what you show a person).
- **Creating customers**: the row carries `errors` and `warnings`, lists of
  `{ field, value, message }`; only some errors add a `code` (for example
  `NIF_INVALID_FORMAT`). Branch on the row's `status`, and use `field` to point at the
  offending value.

A row that created something carries the new identifier (`customer_id`, `product_id`); a row
that did not, doesn't.

What varies between operations is the vocabulary of `status`: creating customers speaks
of `VALID`, `WARNING`, `ERROR`, `DUPLICATE`, `NIF_INVALID`; creating products of
`CREATED`, `DUPLICATE`, `INVALID`; deleting customers of `DELETED`, `NOT_FOUND`,
`HAS_INVOICES`, `HAS_RECURRING_INVOICE`, `ERROR`.

## Invoice batches

One operation, applied to up to 50 invoices:

- **`ISSUE`** issues draft invoices, each one getting its definitive number.
- **`STATUS`** moves invoices to `new_status`: `SENT` or `PAID`. `PAID` also needs
  `payment_date`; without it the whole request answers `400` [`PAYMENT_DATE_REQUIRED`](/errors/PAYMENT_DATE_REQUIRED). Any other
  `new_status` answers `422` [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR).

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices/batches" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Idempotency-Key: month-end-issue-2026-09" \
  -H "Content-Type: application/json" \
  -d '{
    "operation": "ISSUE",
    "invoice_ids": [
      "550e8400-e29b-41d4-a716-446655440000",
      "550e8400-e29b-41d4-a716-446655440001"
    ]
  }'
```

```json
{
  "success": true,
  "data": {
    "total": 2,
    "successful": 1,
    "failed": 1,
    "failures": [
      {
        "invoice_id": "550e8400-e29b-41d4-a716-446655440001",
        "reason": "…"
      }
    ]
  }
}
```

The invoice batch has its own, simpler report: counters plus a `failures` list keyed by
`invoice_id`, whose `reason` is a translated message with no code. Invoices that succeeded
are not listed. An unknown ID is a failure of its own, not an error of the request, and a batch
where every invoice failed still answers `200`. More than 50 IDs answer
`422` [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR).

<Callout type="warn">
  **A batch is not atomic, and issuing cannot be undone.** Each invoice is processed on its
  own: if the tenth fails, the first nine are already issued and stay issued. Retry only the
  invoices listed in `failures`, after fixing the cause — do not resend the whole batch.
</Callout>

Marking as paid:

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices/batches" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operation": "STATUS",
    "new_status": "PAID",
    "payment_date": "2026-09-30",
    "invoice_ids": ["550e8400-e29b-41d4-a716-446655440000"]
  }'
```

`Idempotency-Key` is accepted on batches and deliveries. Use one: a network retry of an
`ISSUE` batch without it is a second batch. See [Idempotency](/guides/idempotency).

## PDF archive

Returns one ZIP with the PDFs of up to 500 invoices.

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices/pdf-archive" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "invoice_ids": ["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"] }' \
  -o invoices.zip -D headers.txt
```

- Invoices whose PDF is not available are **left out** of the ZIP rather than failing the
  call. The response headers `X-Bulk-Total`, `X-Bulk-Successful` and `X-Bulk-Failed` tell
  you how many made it in — check them, since the body is a binary you cannot inspect for
  a report.
- A draft is not left out: it goes in as a draft PDF, named `borrador_<invoice_id>.pdf`
  instead of `factura_<number>.pdf`.
- If **no** PDF at all is available, there is no empty ZIP: the call fails with
  `400` [`NO_PDFS_AVAILABLE`](/errors/NO_PDFS_AVAILABLE).

## Deliveries: many invoices in one email

Sends **one** email carrying the PDFs of up to 200 invoices, to the addresses you give.

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices/deliveries" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Idempotency-Key: accountant-q3-2026" \
  -H "Content-Type: application/json" \
  -d '{
    "invoice_ids": [
      "550e8400-e29b-41d4-a716-446655440000",
      "550e8400-e29b-41d4-a716-446655440001"
    ],
    "recipients": ["accountant@yourdomain.com"],
    "subject": "Invoices Q3 2026",
    "language": "en"
  }'
```

- `recipients` is **required** and must carry at least one address. Nothing is inferred
  from the customers of the invoices: without it the request answers
  `422` [`INVOICE_EMAIL_NO_RECIPIENTS`](/errors/INVOICE_EMAIL_NO_RECIPIENTS), the same code as a single send.
- `cc` omitted means your configured email defaults apply; send `[]` for no copies.
- With more than 5 invoices, the PDFs travel as a single ZIP attachment instead of
  individual files.
- Invoices whose PDF cannot be attached come back in `failures`, each with an `error_code`
  (`NOT_FOUND`, `PDF_NOT_GENERATED`, `INVALID_STATE`, `INTERNAL_ERROR`), and the email is
  still sent with the rest. A draft or a scheduled invoice is `INVALID_STATE`; an invoice whose PDF does not exist
  yet is `PDF_NOT_GENERATED`. `invoices_attached` against `total_invoices` tells you whether
  anything was left out.

A delivery costs one email against your sending quota, however many invoices it carries —
when that helps and when it does not is in
[Sending email](/guides/sending-email#where-bulk-delivery-helps-and-where-it-does-not).

## Exports

Returns a spreadsheet (`.xlsx`) in the response body.

- **`format`:** `SUMMARY` (the default) writes one row per invoice with its totals; `ITEMS`
  writes one row per invoice line.
- **Selection:** either `invoice_ids`, or `filters` (`status`, `type`, `date_from`,
  `date_to`, `customer_id`, `recipient_name`, `recipient_nif`, `series_code`). When
  `invoice_ids` is present, `filters` is ignored. With neither — an empty `filters` object
  counts as neither — the request is rejected with
  `400` [`EXPORT_SELECTION_REQUIRED`](/errors/EXPORT_SELECTION_REQUIRED).
- **What a filter leaves out, the export includes.** Without `status` and `type`, a date
  range exports **every** document in it: drafts, scheduled invoices, voided invoices and
  proformas included. For a file of fiscal documents only, filter by `type` and `status`.
- **Limit:** 50,000 invoices. A wider selection is rejected with
  `422` [`EXPORT_LIMIT_EXCEEDED`](/errors/EXPORT_LIMIT_EXCEEDED) — never truncated. Narrow the date
  range or split the export.
- The `X-Total-Invoices` header carries how many invoices the file contains.

A quarterly line-by-line export:

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices/exports" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "ITEMS",
    "filters": {
      "date_from": "2026-07-01",
      "date_to": "2026-09-30"
    }
  }' \
  -o invoices-q3.xlsx
```

`date_from` and `date_to` filter by issue date and are both inclusive.

> **Rules that apply here:** [CON-003 · Export your invoices before leaving](/rules/conservation#con-003) · [CON-004 · Invoices kept electronically are reachable on request](/rules/conservation#con-004)

## Customers in bulk

### Creating: atomic, with a dry run

Creating customers in bulk is **all or nothing**, up to 500 per request. If any customer
cannot be created, the whole batch is rejected with
`422` [`BULK_VALIDATION_ERROR`](/errors/BULK_VALIDATION_ERROR) and nothing is saved. The rejected
rows come in `error.errors[]`, each with its `index`, `field` and `message`.

Because of that, run it twice: first with `dry_run=true`, which validates everything —
tax identifiers against the AEAT register, duplicates inside the batch and against your
existing customers, field formats — writes nothing and answers `200` with the full
per-row report. Fix what it reports, then send the same body without `dry_run` to create.
Every row error in that report carries a `code` to compare, the same one the single create
answers for the same failure ([`CLIENT_DUPLICATE`](/errors/CLIENT_DUPLICATE), [`NIF_INVALID_FORMAT`](/errors/NIF_INVALID_FORMAT)…), or one of its own for
what only a batch can have, such as [`CUSTOMER_DUPLICATED_IN_BATCH`](/errors/CUSTOMER_DUPLICATED_IN_BATCH).

If the AEAT census cannot be reached to check the tax identifiers, no row is blamed for it: the
whole request answers `502` or `503`, nothing is saved, and you repeat it later.

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/customers/bulk?dry_run=true" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customers": [
      {
        "legal_name": "Acme Consulting SL",
        "nif": "B12345674",
        "email": "billing@acme-consulting.es",
        "address": {
          "street": "Calle Mayor 10",
          "postal_code": "28001",
          "city": "Madrid",
          "province": "Madrid"
        }
      }
    ]
  }'
```

Every customer needs a complete `address` — street, postal code, city and province; the
street number is optional — as in the [file import](/guides/importing-data#rows-without-a-complete-address).
A dry run answers `200` and a real run `201`, both with the same per-row report.

Each entry of `data.customers_validation` carries its `index`, its `status` and any
`errors` or `warnings`. A row with only `warnings` is importable; `statistics.importable`
and `statistics.not_importable` give the totals. On the real run, `statistics.imported` is
how many were created and each created row carries its `customer_id`.

### Deleting: partial

`DELETE …/customers/bulk?ids=…` takes up to 100 comma-separated IDs and answers `200`
even if nothing could be deleted. Read `data.customers_deletion`: a customer that has
invoices comes back as `HAS_INVOICES`, and one used by an active or paused recurring
invoice as `HAS_RECURRING_INVOICE`. Neither will ever succeed on retry — deactivate the
customer instead (`PATCH` with `active: false`).

## Products in bulk

### Creating: partial

Up to 100 products per request. Each row is processed independently: a duplicate `code`
or an invalid value comes back in the report while the rest are created. The answer is
`201` whenever the batch was processed, even if no product was created.

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/products/bulk" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Idempotency-Key: catalog-sync-2026-09-24" \
  -H "Content-Type: application/json" \
  -d '{
    "products": [
      {
        "code": "CONS-HOUR",
        "name": "Consulting hour",
        "default_price": 60,
        "main_tax": { "type": "IVA", "percentage": 21 }
      },
      {
        "code": "WEB-MAINT",
        "name": "Website maintenance",
        "default_price": 120,
        "main_tax": { "type": "IVA", "percentage": 21 }
      }
    ]
  }'
```

Read `data.products_creation`: each row has its `index` and a `status` of `CREATED` (with
`product_id`), `DUPLICATE` or `INVALID` (with `error.code` `PRODUCT_DUPLICATE` or
`PRODUCT_INVALID`). `data.created_products` holds the products that were created.

A malformed request is rejected before any row is processed, and `error.details` names the
field with its index. A missing `name` or more than 100 items answer `422` [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR)
(`products[1].name`); a value of the wrong type answers `400` [`INVALID_JSON_FORMAT`](/errors/INVALID_JSON_FORMAT)
(`error.details.field`: `products[1].main_tax.percentage`).

### Deleting: partial

`DELETE …/products/bulk?ids=…` takes up to 100 comma-separated IDs and answers `200` with
`deleted_products`, the `errors` for the ones that could not be deleted (each with its
`product_id` and an `error` that is a plain message, not an object), and the counts in
`summary`.

## Gotchas

- **A success status is not a clean batch** on partial operations. Always read the report.
- **Invoice batches are not atomic**, and issuing is permanent. Retry only what failed.
- **Customer creation is atomic**; product creation is not. A duplicate blocks every
  customer in the batch, but only its own product.
- **A PDF archive can be short.** Compare `X-Bulk-Successful` with `X-Bulk-Total`.
- **A delivery needs explicit `recipients`.** It never falls back to the customers' emails.
- **Exports never truncate.** Over 50,000 invoices is an error, not a partial file.
- **Pace large runs** against the [rate limits](/guides/rate-limits): one bulk request
  instead of N single ones is usually the fix.

## Related

<Related>

- [Limits and pagination](/guides/limits-and-pagination) — every batch size in one place
- [Importing data](/guides/importing-data) — load customers from a CSV or a Holded export
- [Idempotency](/guides/idempotency) — retry a bulk call without doing the work twice
- [Sending email](/guides/sending-email) — the quotas bulk sending counts against

</Related>

---

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