NewProvince is only required for addresses in Spain
BeeL
Get startedMulti-NIFVeriFactuRulesStripeAPI referenceChangelog

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…OperationReturns
Issue drafts, or mark invoices sent or paidPOST …/invoices/batchesA report per invoice
Download many PDFsPOST …/invoices/pdf-archiveOne ZIP
Email many invoices in one messagePOST …/invoices/deliveriesThe result of the send
Get a spreadsheetPOST …/invoices/exportsOne .xlsx
Create customersPOST …/customers/bulkA report per row
Delete customersDELETE …/customers/bulkA report per row
Create productsPOST …/products/bulkA report per row
Delete productsDELETE …/products/bulkA report of deleted and failed IDs

All paths hang off /v1/companies/{company_id}. Per-request maximums are listed in Limits and pagination.

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.

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.

OperationKind
Invoice batch (ISSUE, STATUS)Partial
Invoice deliveryPartial — invoices that cannot be attached are reported and the email still goes out with the rest
PDF archivePartial — missing PDFs are left out of the ZIP
Create customersAtomic
Delete customersPartial
Create productsPartial
Delete productsPartial

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. Any other new_status answers 422 VALIDATION_ERROR.
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"
    ]
  }'
{
  "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.

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.

Marking as paid:

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.

PDF archive

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

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.

Deliveries: many invoices in one email

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

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

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.
  • 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 — 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:

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.

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 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, NIF_INVALID_FORMAT…), or one of its own for what only a batch can have, such as 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.

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

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 (products[1].name); a value of the wrong type answers 400 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: one bulk request instead of N single ones is usually the fix.