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 | A report per invoice |
| Download many PDFs | POST …/invoices/pdf-archive | One ZIP |
| Email many invoices in one message | POST …/invoices/deliveries | The result of the send |
| Get a spreadsheet | POST …/invoices/exports | One .xlsx |
| Create customers | POST …/customers/bulk | A report per row |
| Delete customers | DELETE …/customers/bulk | A report per row |
| Create products | POST …/products/bulk | A report per row |
| Delete products | DELETE …/products/bulk | A 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(or201when 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
201when everything went in, and a real error —422with 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
4xxbefore 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.
| 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
errorwith a stable, uppercasecode(what you compare, such asCLIENT_HAS_INVOICESorPRODUCT_DUPLICATE) and amessagealready translated to the request language (what you show a person). - Creating customers: the row carries
errorsandwarnings, lists of{ field, value, message }; only some errors add acode(for exampleNIF_INVALID_FORMAT). Branch on the row'sstatus, and usefieldto 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:
ISSUEissues draft invoices, each one getting its definitive number.STATUSmoves invoices tonew_status:SENTorPAID.PAIDalso needspayment_date; without it the whole request answers400PAYMENT_DATE_REQUIRED. Any othernew_statusanswers422VALIDATION_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-SuccessfulandX-Bulk-Failedtell 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>.pdfinstead offactura_<number>.pdf. - If no PDF at all is available, there is no empty ZIP: the call fails with
400NO_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"
}'recipientsis required and must carry at least one address. Nothing is inferred from the customers of the invoices: without it the request answers422INVOICE_EMAIL_NO_RECIPIENTS, the same code as a single send.ccomitted 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 anerror_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 isINVALID_STATE; an invoice whose PDF does not exist yet isPDF_NOT_GENERATED.invoices_attachedagainsttotal_invoicestells 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;ITEMSwrites one row per invoice line.- Selection: either
invoice_ids, orfilters(status,type,date_from,date_to,customer_id,recipient_name,recipient_nif,series_code). Wheninvoice_idsis present,filtersis ignored. With neither — an emptyfiltersobject counts as neither — the request is rejected with400EXPORT_SELECTION_REQUIRED. - What a filter leaves out, the export includes. Without
statusandtype, a date range exports every document in it: drafts, scheduled invoices, voided invoices and proformas included. For a file of fiscal documents only, filter bytypeandstatus. - Limit: 50,000 invoices. A wider selection is rejected with
422EXPORT_LIMIT_EXCEEDED— never truncated. Narrow the date range or split the export. - The
X-Total-Invoicesheader 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.xlsxdate_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-SuccessfulwithX-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.
Related
Sending email
How BeeL. delivers invoice emails — the sandbox recipient restriction, per-account send quotas, queued delivery, and bulk limits.
Importing data
Bring an existing customer base into a company from a CSV or a Holded export, rehearse it first, and create customers or products in bulk from JSON.