Bulk operations
Partial-success contract for bulk endpoints — total, successful, failed and a per-row failures list.
Bulk endpoints process several rows in a single request and never fail the whole batch because one row was rejected. Each row is evaluated independently and the response reports, per row, whether it was applied or not. This is the partial-success contract, shared by every bulk endpoint of the public API.
The bulk surface now spans delete, create, pdf, send and status
operations across the document and catalog resources — not just the original
bulk-delete family. Some return the BulkPartialSuccessResult shape below,
bulk-create returns the richer BulkCreateResult, and bulk-pdf streams a
binary ZIP instead of the JSON envelope. Every one of them honours
partial-success: one bad row never sinks the batch.
Response shape
A bulk operation always returns 200 OK with a BulkPartialSuccessResult
inside data:
{
"data": {
"total": 3,
"successful": 2,
"failed": 1,
"failures": [
{
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"error_code": "resource_not_deletable",
"error_message": "La factura ya está emitida y no se puede eliminar."
}
]
}
}| Field | Type | Meaning |
|---|---|---|
total | integer | Rows processed (successful + failed). |
successful | integer | Rows applied (deleted, created or validated). |
failed | integer | Rows that could not be processed. Equals failures length. |
failures | array | One item per failed row. Always a list — empty, never null, when nothing failed. |
The invariant total === successful + failed and failed === failures.length
always holds. A fully successful batch returns failures: [].
A failure item
Each entry in failures identifies the row and explains why it was
rejected. Identity is polymorphic:
id— the UUID of an existing resource (bulk-delete and other operations over existing resources).index— the 0-based position of the row in the batch, for new rows that do not have a resource yet (bulk-create withdry_run, CSV import).
Exactly one of id / index is present.
| Field | Type | Meaning |
|---|---|---|
id | string | UUID v7 of the existing resource that could not be processed. |
index | integer | 0-based position of the row within the batch. |
error_code | string | Machine-readable code from the v1 error catalog (stable across languages). Branch on this. |
error_message | string | Human-readable reason, in Spanish. For display, not for branching. |
errors | array | Per-field blocking issues (FieldIssue[]). Present in validate-only / bulk-create flows; absent for bulk-delete. |
warnings | array | Per-field non-blocking warnings (FieldIssue[]). |
Branch on error_code, never on error_message — the message is
Spanish, human-facing text and may change. For bulk-delete the codes are
resource_not_found (the UUID does not exist or belongs to another
company) and resource_not_deletable (the resource exists but its state
forbids deletion: a signed delivery note, an invoiced quote, a client
with documents, etc.).
Reading the result
Don't treat the call as all-or-nothing. Inspect failures and act per row:
curl -s -X POST https://api.factuarea.com/v1/quotes/bulk-delete \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": ["01931b3e-...a01", "01931b3e-...a02", "01931b3e-...a03"] }' \
| jq '.data | {total, successful, failed, failures}'const { data } = await factuarea.quotes.bulkDelete({ ids });
if (data.failed > 0) {
for (const f of data.failures) {
// f.id, f.error_code, f.error_message
console.warn(`${f.id} → ${f.error_code}: ${f.error_message}`);
}
}res = factuarea.quotes.bulk_delete(ids=ids)
data = res["data"]
for f in data["failures"]:
# branch on error_code, show error_message
print(f["id"], f["error_code"], f["error_message"])Foreign and unknown UUIDs
UUIDs that do not belong to your company, or that do not exist, are
never a global 404. The handler filters by company_id, so a foreign
or unknown UUID is reported as a normal failure (resource_not_found) — it
never leaks whether a resource exists in another tenant.
Bulk create (validate-only with dry_run)
bulk-create accepts up to 100 rows for invoices and up to 500 for
clients in a single call, and returns a richer envelope, BulkCreateResult.
The dry_run flag (default false) switches between two behaviours:
dry_run: truevalidates each row without persisting anything and returns a per-rowresults[]. Every entry carries itsindex, astatus, and theerrors[]/warnings[]found for that row. Nothing is written — use it to surface problems in your UI before you commit.dry_run: falsecreates only the valid rows. Rows that fail validation are not created and come back infailures[], each identified by its 0-basedindex.
The envelope carries both arrays so the same parser works in either mode:
{
"data": {
"dry_run": true,
"total": 2,
"successful": 1,
"failed": 1,
"results": [
{ "index": 0, "status": "valid", "errors": [], "warnings": [] },
{
"index": 1,
"status": "invalid",
"errors": [{ "field": "client_id", "code": "required", "message": "El cliente es obligatorio." }],
"warnings": []
}
],
"failures": [
{ "index": 1, "error_code": "validation_failed", "error_message": "Faltan campos obligatorios en la fila." }
]
}
}Validate first with dry_run: true, fix what results[] flags, then resend
the same payload with dry_run: false to persist the rows that pass:
curl -s -X POST https://api.factuarea.com/v1/invoices/bulk-create \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2b7a-0e44-4c1a-8f3d-1a2b3c4d5e6f" \
-d '{
"dry_run": true,
"invoices": [
{ "client_id": "01931b3e-...c01", "lines": [{ "description": "Consulting", "quantity": 1, "unit_price": "100.00", "tax_rate_id": "01931b3e-...t21" }] },
{ "lines": [{ "description": "Missing client", "quantity": 1, "unit_price": "50.00" }] }
]
}' | jq '.data | {dry_run, total, successful, failed, results, failures}'Bulk PDF (ZIP download)
bulk-pdf packages the PDFs of up to 50 documents into a single ZIP and
streams the binary archive back — it does not return the JSON
envelope. Ids that are not found, or that have no PDF available, do not
abort the request: the ZIP carries only the valid documents, and the per-id
counts travel in X-Bulk-* response headers so you can reconcile what made it
in.
curl -s -X POST https://api.factuarea.com/v1/invoices/bulk-pdf \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": ["01931b3e-...a01", "01931b3e-...a02"] }' \
-D - -o invoices.zipThe -D - flag dumps the response headers: read X-Bulk-Total,
X-Bulk-Successful and X-Bulk-Failed to know how many ids made it into the
archive.
Bulk send
bulk-send queues up to 200 documents to be emailed and returns the
BulkPartialSuccessResult shape. Delivery is asynchronous: a successful row
means the email was queued, not yet delivered. The optional to, cc,
subject, message and language fields override the defaults for the whole
batch.
curl -s -X POST https://api.factuarea.com/v1/quotes/bulk-send \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c3e1f90-2a11-4b22-9d44-5e6f7a8b9c0d" \
-d '{ "ids": ["01931b3e-...a01", "01931b3e-...a02"], "language": "es" }' \
| jq '.data | {total, successful, failed, failures}'Bulk status transitions
bulk-status moves up to 50 documents to a new status, each transition
going through the Aggregate guard — a row whose current state forbids the
move fails individually and lands in failures[], while the rest still
transition. The target new_status must belong to the closed set allowed for
that resource (see the table below).
For invoices, payment_date is required when new_status is paid. For
purchase invoices, payment_date is likewise required and propagated as
given — it is never silently replaced with now(). For products and
suppliers the transition is idempotent: a resource already in the
requested state counts as successful without flipping anything.
curl -s -X POST https://api.factuarea.com/v1/invoices/bulk-status \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3b8d2e10-4f55-4c66-8a77-9b0c1d2e3f40" \
-d '{ "ids": ["01931b3e-...a01", "01931b3e-...a02"], "new_status": "paid", "payment_date": "2026-03-20" }' \
| jq '.data | {total, successful, failed, failures}'The allowed new_status values per resource:
| Resource | Allowed new_status |
|---|---|
invoices | sent, paid |
quotes | approved, rejected |
proformas | accepted, rejected |
delivery_notes | delivered, cancelled |
purchase_invoices | paid |
products | active, inactive (idempotent) |
suppliers | active, inactive (idempotent) |
Endpoints and limits
Each operation caps the batch at a fixed number of rows. Splitting a larger job into chunks within these limits is up to you:
| Operation | Resources | Max rows | Response shape |
|---|---|---|---|
bulk-create | invoices (100), clients (500) | 100 / 500 | BulkCreateResult |
bulk-pdf | invoices, quotes, proformas, delivery_notes | 50 | binary ZIP + X-Bulk-* |
bulk-send | invoices, quotes, proformas, delivery_notes | 200 | BulkPartialSuccessResult |
bulk-status | invoices, quotes, proformas, delivery_notes, purchase_invoices, products, suppliers | 50 | BulkPartialSuccessResult |
bulk-delete | all nine resources | — | BulkPartialSuccessResult |
Use Idempotency-Key on the mutating bulk ops. bulk-create,
bulk-send, bulk-status and bulk-delete all accept the
Idempotency-Key header, so a retry after a dropped connection replays the
original result instead of running the batch twice. bulk-pdf is a pure read
and needs no key.
Versioning — the legacy shape
The partial-success shape is the current contract. Integrators anchored
to a version before 2026-09-01 (via the Factuarea-Version header or a
pin on the API key) keep receiving the previous bulk-delete shape, so no
existing integration breaks:
{
"object": "bulk_delete_result",
"deleted": 2,
"failed": [
{ "id": "01931b3e-...a01", "reason": "La factura ya está emitida y no se puede eliminar." }
]
}The mapping between the two shapes is mechanical: deleted is the new
successful, and each legacy failed[].reason is the new
failures[].error_message (the new shape adds the stable error_code and
the total counter on top). Send no header — or a date on or after
2026-09-01 — to get the partial-success shape.
Pin a version only to freeze a contract you already depend on. New
integrations should use the partial-success shape: it carries a stable,
language-independent error_code you can branch on, which the legacy
reason string does not.