Factuarea API

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."
      }
    ]
  }
}
FieldTypeMeaning
totalintegerRows processed (successful + failed).
successfulintegerRows applied (deleted, created or validated).
failedintegerRows that could not be processed. Equals failures length.
failuresarrayOne 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 with dry_run, CSV import).

Exactly one of id / index is present.

FieldTypeMeaning
idstringUUID v7 of the existing resource that could not be processed.
indexinteger0-based position of the row within the batch.
error_codestringMachine-readable code from the v1 error catalog (stable across languages). Branch on this.
error_messagestringHuman-readable reason, in Spanish. For display, not for branching.
errorsarrayPer-field blocking issues (FieldIssue[]). Present in validate-only / bulk-create flows; absent for bulk-delete.
warningsarrayPer-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: true validates each row without persisting anything and returns a per-row results[]. Every entry carries its index, a status, and the errors[] / warnings[] found for that row. Nothing is written — use it to surface problems in your UI before you commit.
  • dry_run: false creates only the valid rows. Rows that fail validation are not created and come back in failures[], each identified by its 0-based index.

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

The -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:

ResourceAllowed new_status
invoicessent, paid
quotesapproved, rejected
proformasaccepted, rejected
delivery_notesdelivered, cancelled
purchase_invoicespaid
productsactive, inactive (idempotent)
suppliersactive, 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:

OperationResourcesMax rowsResponse shape
bulk-createinvoices (100), clients (500)100 / 500BulkCreateResult
bulk-pdfinvoices, quotes, proformas, delivery_notes50binary ZIP + X-Bulk-*
bulk-sendinvoices, quotes, proformas, delivery_notes200BulkPartialSuccessResult
bulk-statusinvoices, quotes, proformas, delivery_notes, purchase_invoices, products, suppliers50BulkPartialSuccessResult
bulk-deleteall nine resourcesBulkPartialSuccessResult

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.

On this page