Procesamiento masivo con lotes
Procesa cientos o miles de documentos con lotes de hasta 100, webhooks firmados, idempotencia, reintentos, límites de peticiones y export combinado.
Cuando tienes muchos documentos a la vez (las facturas del mes, los certificados de toda una temporada, una migración),
no llames a POST /v1/analyze uno a uno esperando cada respuesta. Crea lotes con POST /v1/batches y deja que
Constaia te avise por webhook cuando terminen.
El flujo
Tu servidor Constaia
─────────── ────────
1. POST /v1/webhook-endpoints (una vez) ─────────▶ whsec_… (guárdalo)
2. POST /v1/batches (≤ 100 docs) ───────────────▶ 202 { id: "bat_…", status: "processing" }
cada documento es un análisis con batch_id
3. /webhooks/constaia ◀── analysis.review_required / analysis.failed (por documento, si aplica)
◀── batch.completed (una vez, con counts, analyses y exports)
4. GET /v1/analyses/{id} por cada análisis, o el export combinadoPaso 1: dar de alta el endpoint de webhooks
Hazlo una vez, desde el panel (app.constaia.com → Webhooks) o por API. El secret
(whsec_…) solo se devuelve al crearlo: guárdalo como CONSTAIA_WEBHOOK_SECRET.
curl https://api.constaia.com/v1/webhook-endpoints \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tuapp.example/webhooks/constaia",
"events": ["batch.completed", "analysis.review_required", "analysis.failed"],
"description": "Lotes"
}'El endpoint hereda el modo de la clave con que lo creas: uno creado con ck_test_… solo recibe eventos de test.
Crea uno para cada modo. Detalle en Webhooks.
Paso 2: crear el lote
Un lote admite de 1 a 100 documentos y siempre responde 202. Hay dos formas de enviarlos:
- Multipart con
files[]repetido y un campooptionscomún. Cómodo para ficheros locales. - JSON con
items[], cada uno confile_url(ofile_base64+filename) y sus propiasoptions. Mejor para volúmenes grandes (no subes los ficheros en la petición) y cuando cada documento necesita checks distintos.
Cómo se reparten las opciones:
| Dónde | Se aplica a |
|---|---|
options.export, options.metadata en la raíz | Al lote: export combinado (una fila por documento) y metadatos del lote. |
Resto de options en la raíz (expect, checks, storage…) | A cada documento. |
items[].options | A ese documento. Se fusiona clave a clave con las comunes, y checks también: sus checks se suman a los comunes y, si coinciden, gana el del item. Aquí puedes poner su metadata. |
El lote es atómico: si un fichero o una file_url falla (formato, tamaño, páginas, descarga), la petición devuelve el error de ese documento y no se crea ni se cobra ningún análisis. Corrígelo y reenvía el lote.
curl https://api.constaia.com/v1/batches \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-H "Idempotency-Key: import-2026-09-lote-1" \
-F "files[]=@facturas/invoice_001.pdf" \
-F "files[]=@facturas/invoice_002.pdf" \
-F 'options={"expect":"invoice","export":["xlsx"],"metadata":{"import":"2026-09"}}'Constaia descarga cada file_url al crear el lote (HTTPS, máximo 20 MB y 15 s por fichero), así que con muchos
elementos la petición tarda: usa un timeout amplio. En modo live comprueba antes de empezar que tienes al menos 1
crédito por documento; si no, responde 402 insufficient_credits y no crea nada.
La clave de idempotencia evita lotes duplicados si reintentas tras un corte de red: con la misma clave y el mismo
contenido en 24 h recibes el mismo lote (cabecera Idempotent-Replayed: true) sin volver a cobrar. Los SDK ya envían
una automáticamente en cada POST; pasa la tuya para deduplicar entre procesos o ejecuciones. Ver
Idempotencia.
Paso 3: recibir los webhooks
Qué eventos genera un lote:
| Evento | Cuándo | data |
|---|---|---|
analysis.review_required | Un documento del lote termina con veredicto review. | El análisis |
analysis.failed | Un documento del lote no se ha podido procesar. | El análisis (status: "failed", con error) |
batch.completed | Han terminado todos los documentos. Una sola vez. | El lote |
Los documentos de un lote no envían analysis.completed: el aviso general es batch.completed.
Reglas del manejador: verifica la firma con el cuerpo sin procesar, deduplica por la cabecera webhook-id (es la
misma en todos los reintentos), responde 2xx en menos de 15 segundos y haz el trabajo pesado después.
import express from "express";
import { Constaia, WebhookVerificationError } from "@constaia/sdk";
import { db } from "./db.js";
const app = express();
const constaia = new Constaia();
app.post("/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(400).send("invalid signature");
throw err;
}
// INSERT … ON CONFLICT DO NOTHING sobre webhook-id: si ya existía, es un reintento.
const isNew = await db.inbox.insertIfAbsent(req.headers["webhook-id"], event);
res.sendStatus(200);
if (isNew) handleEvent(event).catch((err) => console.error("webhook", err));
});
async function handleEvent(event) {
switch (event.type) {
case "batch.completed": {
const batch = event.data;
console.log(batch.id, batch.counts); // { completed, failed, valid, invalid, review, … }
for (const id of batch.analyses) {
const analysis = await constaia.analyses.get(id);
await db.results.save({
analysisId: analysis.id,
athleteId: analysis.metadata.athlete_id,
status: analysis.status === "failed" ? "failed" : analysis.verdict?.status,
});
}
if (batch.exports?.xlsx) {
const file = await fetch(batch.exports.xlsx); // URL firmada, caduca en 24 h
await db.files.save(`${batch.id}.xlsx`, Buffer.from(await file.arrayBuffer()));
}
break;
}
case "analysis.review_required":
await db.reviewQueue.add(event.data.id, event.data.metadata);
break;
case "analysis.failed":
await db.results.markFailed(event.data.id, event.data.error?.code);
break;
}
}
app.listen(3000);Si tu endpoint no responde 2xx, Constaia reintenta durante unos 3 días (5 s, 5 min, 30 min, 2 h, 5 h, 10 h…). Las
redirecciones no se siguen.
El objeto lote
{
"id": "bat_01J…",
"object": "batch",
"status": "completed",
"livemode": false,
"total": 2,
"counts": { "queued": 0, "processing": 0, "completed": 2, "failed": 0, "valid": 1, "invalid": 1, "review": 0 },
"analyses": ["an_01J…", "an_01J…"],
"exports": { "xlsx": "https://api.constaia.com/v1/files/exp_…?expires=…&sig=…" },
"metadata": { "season": "2026-27" },
"created_at": "2026-09-29T10:00:00Z",
"completed_at": "2026-09-29T10:00:41Z"
}El export combinado tiene una fila por documento, con el veredicto, los motivos, los campos y una columna por cada clave
de metadata de cada análisis. Ver Exportaciones.
Más de 100 documentos
Divide en trozos de 100 y crea los lotes uno detrás de otro, cada uno con su clave de idempotencia estable. Si el script se corta, al relanzarlo los lotes ya creados se devuelven sin duplicarse (dentro de 24 h).
import { Constaia } from "@constaia/sdk";
import { listPendingInvoiceUrls } from "./storage.js";
const constaia = new Constaia();
const importId = "facturas-2026-09";
const urls = await listPendingInvoiceUrls(); // p. ej. 2.350 URL HTTPS
for (let i = 0; i < urls.length; i += 100) {
const chunk = urls.slice(i, i + 100);
const batch = await constaia.batches.create(
{
items: chunk.map((url) => ({ fileUrl: url })),
options: { expect: "invoice", export: ["xlsx"], metadata: { import: importId } },
},
{ idempotencyKey: `${importId}-${i / 100}`, timeout: 120_000 },
);
console.log(`lote ${i / 100}: ${batch.id} (${batch.total} documentos)`);
}A tener en cuenta:
- Límite de peticiones: 2 peticiones por segundo por clave en el plan gratuito y 10 en el de pago, contando también los
GETque hagas al leer resultados. Las respuestas traenRateLimit-Remainingy, en un429,Retry-After. Los SDK reintentan solos los429y5xxrespetandoRetry-After. Ver Límites. - Concurrencia: los SDK de JavaScript y PHP no limitan cuántas peticiones lanzas a la vez. Si paralelizas (por
ejemplo, los
GET /v1/analyses/{id}trasbatch.completed), limítalo tú, por ejemplo conp-limit. - Créditos: cada documento cuesta 1 crédito por cada 2 páginas. Con miles de documentos, revisa el saldo con
GET /v1/balanceantes de empezar. Ver Créditos y facturación. - PDF largos: en lotes se admiten hasta 200 páginas por PDF.
- Fallidos: los documentos con
status: "failed"no se cobran. Reúne sus URL y crea un lote nuevo con ellos.
Si no te llega el webhook: consultar el lote
Como respaldo (o si no puedes exponer una URL pública, por ejemplo en local), consulta el lote hasta que su status
sea completed. Espacia las consultas: no hace falta preguntar más de una vez cada pocos segundos.
import { setTimeout as sleep } from "node:timers/promises";
import { Constaia } from "@constaia/sdk";
const constaia = new Constaia();
export async function waitForBatch(id) {
for (;;) {
const batch = await constaia.batches.get(id);
if (batch.status === "completed") return batch;
await sleep(5_000);
}
}Probarlo en modo test
Con una clave ck_test_… los lotes funcionan igual y no gastan créditos. El resultado de cada documento depende de su
nombre: invoice_001.pdf, payment_receipt_2.pdf, medical_certificate_a.pdf, dni_expired.jpg o blurry.jpg (este
último genera un analysis.review_required). Para recibir webhooks en local, expón tu servidor con un túnel HTTPS o
usa la consulta del lote. Desde el panel también puedes enviar un evento de prueba a tu endpoint. Más en
Modo test.
Siguientes pasos
Facturas a Excel
Extrae número, emisor, receptor, líneas e IVA de facturas en PDF o foto, valida totales y NIF y descarga un Excel por factura o por lote.
Solo analizar sin guardar
Minimización de datos (RGPD) con Constaia. Analiza documentos sin guardar el fichero ni los datos extraídos con storage none y keep_results false.