Constaia
Use-case guides

Bulk processing with batches

Process hundreds or thousands of documents with batches of up to 100, signed webhooks, idempotency keys, retries, rate limits and a combined export.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

When you have many documents at once (the month's invoices, a whole season's certificates, a migration), don't call POST /v1/analyze one by one waiting for each response. Create batches with POST /v1/batches and let Constaia notify you by webhook when they finish.

The flow

Your server                                        Constaia
───────────                                        ────────
1. POST /v1/webhook-endpoints (once) ────────────▶ whsec_… (store it)
2. POST /v1/batches  (≤ 100 docs) ───────────────▶ 202 { id: "bat_…", status: "processing" }
                                                   each document is an analysis with batch_id
3. /webhooks/constaia ◀── analysis.review_required / analysis.failed (per document, when relevant)
                      ◀── batch.completed (once, with counts, analyses and exports)
4. GET /v1/analyses/{id} for each analysis, or the combined export

Step 1: register the webhook endpoint

Do this once, from the dashboard (app.constaia.com → Webhooks) or via the API. The secret (whsec_…) is only returned on creation: store it as 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://yourapp.example/webhooks/constaia",
    "events": ["batch.completed", "analysis.review_required", "analysis.failed"],
    "description": "Batches"
  }'

The endpoint inherits the mode of the key you create it with: one created with ck_test_… only receives test events. Create one per mode. Details in Webhooks.

Step 2: create the batch

A batch takes 1 to 100 documents and always responds 202. There are two ways to send them:

  • Multipart with repeated files[] and a common options field. Handy for local files.
  • JSON with items[], each with file_url (or file_base64 + filename) and its own options. Better for large volumes (you don't upload the files in the request) and when each document needs different checks.

How options are applied:

WhereApplies to
options.export, options.metadata at the rootThe batch: combined export (one row per document) and batch metadata.
Other root options (expect, checks, storage…)Each document.
items[].optionsThat document. Merged key by key with the common ones, and so is checks: its checks are added to the common ones and, on the same key, the item wins. Put its metadata here.

The batch is atomic: if a file or a file_url fails (format, size, pages, download), the request returns that document's error and no analysis is created or charged. Fix it and resend the batch.

Terminal
curl https://api.constaia.com/v1/batches \
  -H "Authorization: Bearer $CONSTAIA_API_KEY" \
  -H "Idempotency-Key: import-2026-09-batch-1" \
  -F "files[]=@invoices/invoice_001.pdf" \
  -F "files[]=@invoices/invoice_002.pdf" \
  -F 'options={"expect":"invoice","export":["xlsx"],"metadata":{"import":"2026-09"}}'

Constaia downloads each file_url when the batch is created (HTTPS, max 20 MB and 15 s per file), so with many items the request takes a while: use a generous timeout. In live mode it checks up front that you have at least 1 credit per document; otherwise it responds 402 insufficient_credits and creates nothing.

The idempotency key prevents duplicate batches if you retry after a network failure: with the same key and the same content within 24 h you get the same batch back (header Idempotent-Replayed: true) without being charged again. The SDKs already send one automatically on every POST; pass your own to dedupe across processes or runs. See Idempotency.

Step 3: receive the webhooks

Events a batch generates:

EventWhendata
analysis.review_requiredA document in the batch finishes with a review verdict.The analysis
analysis.failedA document in the batch couldn't be processed.The analysis (status: "failed", with error)
batch.completedAll documents have finished. Once.The batch

Documents in a batch do not send analysis.completed: the general notification is batch.completed.

Handler rules: verify the signature with the raw body, dedupe on the webhook-id header (it's the same across all retries), respond 2xx within 15 seconds and do the heavy work afterwards.

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 on webhook-id: if it already existed, it's a retry.
  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); // signed URL, expires in 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);

If your endpoint doesn't respond 2xx, Constaia retries for about 3 days (5 s, 5 min, 30 min, 2 h, 5 h, 10 h…). Redirects are not followed.

The batch object

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"
}

The combined export has one row per document, with the verdict, reasons, fields and one column per metadata key of each analysis. See Exports.

More than 100 documents

Split into chunks of 100 and create the batches one after another, each with its own stable idempotency key. If the script stops, re-running it returns the batches already created without duplicating them (within 24 h).

scripts/import-all.js
import { Constaia } from "@constaia/sdk";
import { listPendingInvoiceUrls } from "./storage.js";

const constaia = new Constaia();
const importId = "invoices-2026-09";
const urls = await listPendingInvoiceUrls(); // e.g. 2,350 HTTPS URLs

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(`batch ${i / 100}: ${batch.id} (${batch.total} documents)`);
}

Keep in mind:

  • Rate limit: 2 requests per second per key on the free plan (10 on paid), including the GETs you make to read results. Responses carry RateLimit-Remaining and, on a 429, Retry-After. The SDKs retry 429 and 5xx automatically honouring Retry-After. See Rate limits.
  • Concurrency: the JavaScript and PHP SDKs don't limit how many requests you fire at once. If you parallelise (for example the GET /v1/analyses/{id} calls after batch.completed), limit it yourself, e.g. with p-limit.
  • Credits: each document costs 1 credit per 2 pages. With thousands of documents, check your balance with GET /v1/balance before starting. See Credits and billing.
  • Long PDFs: batches accept up to 200 pages per PDF.
  • Failures: documents with status: "failed" are not charged. Collect their URLs and create a new batch with them.

If the webhook doesn't arrive: poll the batch

As a fallback (or if you can't expose a public URL, e.g. locally), poll the batch until its status is completed. Space out the calls: there's no need to ask more than once every few seconds.

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);
  }
}

Test it in test mode

With a ck_test_… key batches work the same and spend no credits. Each document's result depends on its name: invoice_001.pdf, payment_receipt_2.pdf, medical_certificate_a.pdf, dni_expired.jpg or blurry.jpg (the last one triggers an analysis.review_required). To receive webhooks locally, expose your server through an HTTPS tunnel or poll the batch. From the dashboard you can also send a test event to your endpoint. More in Test mode.

Next steps

Nesta página