Operaciones en lote
Contrato de éxito parcial para endpoints bulk — total, successful, failed y una lista failures por fila.
Los endpoints bulk procesan varias filas en una sola petición y nunca hacen fallar todo el lote porque una fila se rechace. Cada fila se evalúa de forma independiente y la respuesta informa, fila a fila, de si se aplicó o no. Es el contrato de éxito parcial (partial-success), compartido por todos los endpoints bulk de la API pública.
La superficie bulk ahora abarca operaciones de delete, create, pdf, send y
status sobre los recursos de documento y de catálogo — no solo la familia
original bulk-delete. Algunas devuelven la forma BulkPartialSuccessResult
de abajo, bulk-create devuelve la forma más rica BulkCreateResult, y
bulk-pdf transmite un ZIP binario en lugar del envoltorio JSON. Todas honran
el éxito parcial: una fila mala nunca hunde el lote.
Forma de la respuesta
Una operación bulk siempre devuelve 200 OK con un
BulkPartialSuccessResult dentro de 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."
}
]
}
}| Campo | Tipo | Significado |
|---|---|---|
total | integer | Filas procesadas (successful + failed). |
successful | integer | Filas aplicadas (eliminadas, creadas o validadas). |
failed | integer | Filas que no se pudieron procesar. Igual a la longitud de failures. |
failures | array | Un elemento por cada fila fallida. Siempre una lista — vacía, nunca null, cuando no falló nada. |
El invariante total === successful + failed y failed === failures.length
se cumple siempre. Un lote totalmente correcto devuelve failures: [].
Un elemento de fallo
Cada entrada de failures identifica la fila y explica por qué se rechazó.
La identidad es polimórfica:
id— el UUID de un recurso existente (bulk-delete y otras operaciones sobre recursos existentes).index— la posición (base 0) de la fila en el lote, para filas nuevas que aún no tienen recurso (bulk-create condry_run, import CSV).
Exactamente uno de id / index está presente.
| Campo | Tipo | Significado |
|---|---|---|
id | string | UUID v7 del recurso existente que no se pudo procesar. |
index | integer | Posición (base 0) de la fila dentro del lote. |
error_code | string | Código legible por máquina del catálogo de errores v1 (estable entre idiomas). Ramifica según esto. |
error_message | string | Motivo legible por humanos, en español. Para mostrar, no para ramificar. |
errors | array | Problemas bloqueantes por campo (FieldIssue[]). Presentes en flujos validate-only / bulk-create; ausentes en bulk-delete. |
warnings | array | Avisos no bloqueantes por campo (FieldIssue[]). |
Ramifica según error_code, nunca según error_message — el mensaje
es texto en español orientado a personas y puede cambiar. Para bulk-delete
los códigos son resource_not_found (el UUID no existe o pertenece a otra
empresa) y resource_not_deletable (el recurso existe pero su estado
impide eliminarlo: un albarán firmado, un presupuesto facturado, un
cliente con documentos, etc.).
Leer el resultado
No trates la llamada como todo-o-nada. Inspecciona failures y actúa fila
a fila:
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"]:
# ramifica según error_code, muestra error_message
print(f["id"], f["error_code"], f["error_message"])UUID ajenos y desconocidos
Los UUID que no pertenecen a tu empresa, o que no existen, nunca son un
404 global. El handler filtra por company_id, así que un UUID ajeno o
desconocido se reporta como un fallo normal (resource_not_found) — nunca
revela si un recurso existe en otro tenant.
Bulk create (solo validar con dry_run)
bulk-create acepta hasta 100 filas para facturas y hasta 500 para
clientes en una sola llamada, y devuelve un envoltorio más rico,
BulkCreateResult. El flag dry_run (por defecto false) alterna entre dos
comportamientos:
dry_run: truevalida cada fila sin persistir nada y devuelve unresults[]por fila. Cada entrada lleva suindex, unstatus, y loserrors[]/warnings[]encontrados para esa fila. No se escribe nada — úsalo para mostrar los problemas en tu interfaz antes de confirmar.dry_run: falsecrea solo las filas válidas. Las filas que fallan la validación no se crean y vuelven enfailures[], cada una identificada por suindex(base 0).
El envoltorio lleva ambos arrays, así que el mismo parser sirve en cualquier modo:
{
"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." }
]
}
}Valida primero con dry_run: true, corrige lo que marque results[] y vuelve
a enviar el mismo payload con dry_run: false para persistir las filas que
pasan:
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 (descarga ZIP)
bulk-pdf empaqueta los PDF de hasta 50 documentos en un único ZIP y
transmite de vuelta el archivo binario — no devuelve el envoltorio
JSON. Los id que no se encuentran, o que no tienen PDF disponible, no
abortan la petición: el ZIP lleva solo los documentos válidos, y los recuentos
por id viajan en cabeceras de respuesta X-Bulk-* para que puedas conciliar
qué entró.
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.zipEl flag -D - vuelca las cabeceras de respuesta: lee X-Bulk-Total,
X-Bulk-Successful y X-Bulk-Failed para saber cuántos id entraron en el
archivo.
Bulk send (envío en lote)
bulk-send encola hasta 200 documentos para enviarlos por email y
devuelve la forma BulkPartialSuccessResult. El envío es asíncrono: una fila
successful significa que el email se encoló, no que ya se entregó. Los
campos opcionales to, cc, subject, message y language sobrescriben
los valores por defecto para todo el lote.
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}'Transiciones de estado en lote
bulk-status mueve hasta 50 documentos a un nuevo estado, y cada
transición pasa por la guarda del Aggregate — una fila cuyo estado actual
prohíbe el movimiento falla de forma individual y cae en failures[],
mientras el resto sí transiciona. El new_status destino debe pertenecer al
conjunto cerrado permitido para ese recurso (consulta la tabla de abajo).
Para facturas, payment_date es obligatorio cuando new_status es
paid. Para facturas de compra, payment_date también es obligatorio y se
propaga tal cual — nunca se reemplaza en silencio por now(). Para
productos y proveedores la transición es idempotente: un recurso que ya
está en el estado solicitado cuenta como successful sin cambiar nada.
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}'Los valores new_status permitidos por recurso:
| Recurso | new_status permitido |
|---|---|
invoices | sent, paid |
quotes | approved, rejected |
proformas | accepted, rejected |
delivery_notes | delivered, cancelled |
purchase_invoices | paid |
products | active, inactive (idempotente) |
suppliers | active, inactive (idempotente) |
Endpoints y límites
Cada operación limita el lote a un número fijo de filas. Trocear un trabajo mayor en bloques dentro de estos límites queda de tu parte:
| Operación | Recursos | Filas máx. | Forma de respuesta |
|---|---|---|---|
bulk-create | invoices (100), clients (500) | 100 / 500 | BulkCreateResult |
bulk-pdf | invoices, quotes, proformas, delivery_notes | 50 | ZIP binario + 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 | los nueve recursos | — | BulkPartialSuccessResult |
Usa Idempotency-Key en las operaciones bulk que mutan. bulk-create,
bulk-send, bulk-status y bulk-delete aceptan todas la cabecera
Idempotency-Key, así que un reintento tras una conexión caída repite el
resultado original en lugar de ejecutar el lote dos veces. bulk-pdf es una
lectura pura y no necesita clave.
Versionado — la forma legacy
La forma de éxito parcial es el contrato actual. Los integradores
anclados a una versión anterior a 2026-09-01 (mediante el header
Factuarea-Version o un pin en la API key) siguen recibiendo la forma
anterior de bulk-delete, de modo que ninguna integración existente se rompe:
{
"object": "bulk_delete_result",
"deleted": 2,
"failed": [
{ "id": "01931b3e-...a01", "reason": "La factura ya está emitida y no se puede eliminar." }
]
}El mapeo entre ambas formas es mecánico: deleted es el nuevo
successful, y cada failed[].reason legacy es el nuevo
failures[].error_message (la nueva forma añade encima el error_code
estable y el contador total). No envíes header —o envía una fecha igual o
posterior a 2026-09-01— para obtener la forma de éxito parcial.
Ancla una versión solo para congelar un contrato del que ya dependes. Las
integraciones nuevas deberían usar la forma de éxito parcial: lleva un
error_code estable e independiente del idioma por el que ramificar, cosa
que la cadena reason legacy no ofrece.