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.
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 exportStep 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.
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 commonoptionsfield. Handy for local files. - JSON with
items[], each withfile_url(orfile_base64+filename) and its ownoptions. Better for large volumes (you don't upload the files in the request) and when each document needs different checks.
How options are applied:
| Where | Applies to |
|---|---|
options.export, options.metadata at the root | The batch: combined export (one row per document) and batch metadata. |
Other root options (expect, checks, storage…) | Each document. |
items[].options | That 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.
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:
| Event | When | data |
|---|---|---|
analysis.review_required | A document in the batch finishes with a review verdict. | The analysis |
analysis.failed | A document in the batch couldn't be processed. | The analysis (status: "failed", with error) |
batch.completed | All 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.
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
{
"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).
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 carryRateLimit-Remainingand, on a429,Retry-After. The SDKs retry429and5xxautomatically honouringRetry-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 afterbatch.completed), limit it yourself, e.g. withp-limit. - Credits: each document costs 1 credit per 2 pages. With thousands of documents, check your balance with
GET /v1/balancebefore 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.
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
Invoices to Excel
Extract number, seller, buyer, lines and VAT from PDF or photographed invoices, validate totals and tax IDs and download Excel per invoice or batch.
Analyze without storing
Data minimisation (GDPR) with Constaia. Analyze documents without keeping the file or the extracted data using storage none and keep_results false.