Constaia
Guías por caso

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 combinado

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

Terminal
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 campo options común. Cómodo para ficheros locales.
  • JSON con items[], cada uno con file_url (o file_base64 + filename) y sus propias options. 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óndeSe aplica a
options.export, options.metadata en la raízAl 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[].optionsA 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.

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

EventoCuándodata
analysis.review_requiredUn documento del lote termina con veredicto review.El análisis
analysis.failedUn documento del lote no se ha podido procesar.El análisis (status: "failed", con error)
batch.completedHan 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.

webhooks.js
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

batch.completed → data
{
  "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).

scripts/import-all.js
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 GET que hagas al leer resultados. Las respuestas traen RateLimit-Remaining y, en un 429, Retry-After. Los SDK reintentan solos los 429 y 5xx respetando Retry-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} tras batch.completed), limítalo tú, por ejemplo con p-limit.
  • Créditos: cada documento cuesta 1 crédito por cada 2 páginas. Con miles de documentos, revisa el saldo con GET /v1/balance antes 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.

scripts/wait-batch.js
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

En esta página