Node.js
Use Constaia from plain Node.js: a script that analyses local files and a node:http server with an upload route and a verified webhook.
This guide uses only Node.js and the SDK, with no framework. You will build:
- A script that analyses a local file and prints the verdict. Handy for batch jobs, cron or quick tests.
- A
node:httpserver withPOST /api/verify-dni(uploads from your site or app) andPOST /webhooks/constaia(signed events).
If you already use a framework, go straight to Express, Fastify, Hono, NestJS or Koa.
Install
npm i @constaia/sdk
npm i -D typescript tsx @types/nodeThe SDK has no dependencies and runs on Node.js 18 or later. This guide uses Node.js 20+ (for --env-file and the global File).
Environment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...new Constaia() reads CONSTAIA_API_KEY from the environment. Create the key in the dashboard → API keys; start with a ck_test_ key, which is free and deterministic (Test mode).
1. Script
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia();
const path = process.argv[2] ?? "dni_valid.jpg";
try {
const analysis = await constaia.analyze(await fromPath(path), {
expect: "es_dni",
checks: { notExpired: true },
language: "en",
});
console.log(analysis.document?.label, "→", analysis.verdict?.status);
for (const reason of analysis.verdict?.reasons ?? []) {
console.log(` [${reason.severity}] ${reason.code}: ${reason.message}`);
}
console.log("Number:", analysis.fields.document_number?.value);
console.log("Expires:", analysis.fields.expiry_date?.value);
} catch (error) {
if (!(error instanceof ConstaiaError)) throw error;
console.error(error.name, error.code, error.message, error.requestId);
process.exitCode = 1;
}npx tsx --env-file=.env scripts/verify.ts dni_valid.jpgSpanish ID card (DNI) → valid
[info] type_match: The document is Spanish ID card (DNI).
[info] not_expired: Valid until 12/03/2031.
Number: 12345678Z
Expires: 2031-03-12fromPath (from @constaia/sdk/node) reads the file and keeps its name. With dni_expired.jpg you get an [error] not_expired reason and the verdict invalid.
2. node:http server
Shared helpers
import {
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
type Analysis,
type WebhookEvent,
} from "@constaia/sdk";
// Reads CONSTAIA_API_KEY from the environment.
export const constaia = new Constaia();
// What you send back to the browser (and what the widget renders): no extracted fields.
export function publicResult(analysis: Analysis) {
const { id, object, status, document, verdict, warnings } = analysis;
return { id, object, status, document, verdict, warnings };
}
export type HttpError = {
status: number;
body: { error: { code: string; message: string } };
retryAfter?: number;
};
const httpError = (status: number, code: string, message: string, retryAfter?: number): HttpError => ({
status,
body: { error: { code, message } },
retryAfter,
});
export function toHttpError(error: unknown): HttpError {
if (error instanceof InvalidRequestError) {
// Unreadable file, unsupported format, too many pages…: the user can fix it.
return httpError(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
}
if (error instanceof RateLimitError) {
const wait = Math.ceil(error.retryAfter ?? 1);
return httpError(429, "rate_limited", "Too many requests. Try again in a few seconds.", wait);
}
if (error instanceof InsufficientCreditsError) {
console.error("[constaia] Out of credits. Top up at https://app.constaia.com", error.requestId);
return httpError(503, "unavailable", "Document validation is temporarily unavailable.");
}
if (error instanceof AuthenticationError || error instanceof PermissionError) {
console.error("[constaia] Check CONSTAIA_API_KEY", error.code, error.requestId);
return httpError(500, "misconfigured", "Server configuration error.");
}
if (error instanceof ConstaiaError) {
// APIError (5xx), APIConnectionError, APITimeoutError
console.error("[constaia]", error.name, error.code, error.requestId);
return httpError(502, "upstream_error", "The document could not be analysed. Please try again.");
}
console.error(error);
return httpError(500, "internal_error", "Internal error.");
}
// Dedupe by webhook-id. In production, use a table with a unique key.
const processed = new Set<string>();
export const alreadyProcessed = (webhookId: string) => processed.has(webhookId);
export const markProcessed = (webhookId: string) => void processed.add(webhookId);
export async function handleEvent(event: WebhookEvent) {
switch (event.type) {
case "analysis.completed":
// event.data is the full analysis (with fields). Store it by event.data.id.
console.log("analysis.completed", event.data.id, event.data.verdict?.status);
break;
case "analysis.review_required":
console.log("Needs manual review", event.data.id);
break;
case "analysis.failed":
console.warn("analysis.failed", event.data.id, event.data.error?.code);
break;
case "credits.low":
console.warn("Credits running low: top up at https://app.constaia.com");
break;
}
}Server
Node.js does not parse multipart/form-data by itself, but Response.formData() (from the built-in fetch API) does. The server reads the body with a limit, turns it into FormData and passes the File to the SDK.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { WebhookVerificationError } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
const MAX_UPLOAD = 21 * 1024 * 1024; // 20 MB file + multipart overhead
const MAX_WEBHOOK = 1024 * 1024;
function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer | null> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) return null;
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
// Put your authentication here: only signed-in users should spend credits.
async function verifyDni(req: IncomingMessage, res: ServerResponse) {
const body = await readBody(req, MAX_UPLOAD);
if (!body) return sendJson(res, 413, { error: { code: "file_too_large", message: "20 MB maximum." } });
let form: FormData;
try {
form = await new Response(body, {
headers: { "content-type": req.headers["content-type"] ?? "" },
}).formData();
} catch {
return sendJson(res, 400, { error: { code: "invalid_multipart", message: "Send multipart/form-data." } });
}
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return sendJson(res, 400, { error: { code: "missing_file", message: "No file received." } });
}
const fullName = String(form.get("full_name") ?? "").trim();
try {
const analysis = await constaia.analyze(file, {
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "en",
});
// Store analysis.id and analysis.verdict in your database.
sendJson(res, analysis.status === "completed" ? 200 : 202, publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
sendJson(res, status, body, retryAfter ? { "retry-after": String(retryAfter) } : {});
}
}
async function webhook(req: IncomingMessage, res: ServerResponse) {
const rawBody = await readBody(req, MAX_WEBHOOK);
if (!rawBody) return sendJson(res, 413, { error: { code: "payload_too_large", message: "Too large" } });
let event;
try {
event = await constaia.webhooks.verify(rawBody, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
} catch (error) {
if (!(error instanceof WebhookVerificationError)) throw error;
return sendJson(res, 400, { error: { code: "invalid_signature", message: error.message } });
}
const webhookId = req.headers["webhook-id"] as string;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
res.writeHead(200).end();
}
const server = createServer(async (req, res) => {
try {
if (req.method === "POST" && req.url === "/api/verify-dni") return await verifyDni(req, res);
if (req.method === "POST" && req.url === "/webhooks/constaia") return await webhook(req, res);
sendJson(res, 404, { error: { code: "not_found", message: "Not found" } });
} catch (error) {
console.error(error);
if (!res.headersSent) sendJson(res, 500, { error: { code: "internal_error", message: "Internal error." } });
}
});
server.requestTimeout = 120_000;
server.listen(Number(process.env.PORT ?? 3000), () => console.log("http://localhost:3000"));npx tsx --env-file=.env src/server.tsKey points:
- The server decides
expectandchecks. Don't accept those options from the client. Take the holder's name from the session rather than the form. Filekeeps the original name, which decides the response in test mode.- Raw body in the webhook.
readBodyreturns the exact bytes, which is what Constaia signs. Never verify overJSON.stringify(JSON.parse(body)). - 202. If the analysis doesn't finish within 30 s, the API returns it as
queuedorprocessingand the result arrives by webhook. requestTimeoutcontrols how long Node waits to receive the full request. Keep it above 60 s.
What the browser receives
{
"id": "an_01J…",
"object": "analysis",
"status": "completed",
"document": { "type": "es_dni", "label": "Spanish ID card (DNI)", "confidence": 0.97, "side": "both", "country": "ESP" },
"verdict": {
"expected": ["es_dni"],
"match": true,
"status": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish ID card (DNI)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." },
{ "code": "holder", "severity": "info", "message": "…" }
]
},
"warnings": []
}It is the shape the widget renders if you point it at this route with endpoint="/api/verify-dni".
Errors
| SDK error | When | What your route returns |
|---|---|---|
InvalidRequestError | Empty, unreadable or unsupported file, over 20 MB, too many pages or malformed options (400/409/413/415/422) | 422 (or 413) with the message, so the user can upload another file |
RateLimitError | You exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After | 429 with Retry-After |
InsufficientCreditsError | No credits left (402) | 503 to the user and an alert for you: top up in the dashboard |
AuthenticationError, PermissionError | Missing, revoked or wrong key (401/403) | 500: it is your configuration problem, not the user's |
APIError, APIConnectionError, APITimeoutError | Constaia 5xx or network error, after retries are exhausted | 502 and a "try again" message |
All of them extend ConstaiaError and expose status, code and requestId. Always log the requestId: support will ask for it. Every code is described in Errors.
Test in test mode
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:3000/api/verify-dni| File | full_name | Result |
|---|---|---|
dni_valid.jpg | María García López | valid |
dni_valid.jpg | Juan Pérez | invalid: reason holder with severity error |
dni_expired.jpg | (empty) | invalid: reason not_expired with severity error (expired on 15/06/2020) |
blurry.jpg | (empty) | review: reason low_quality and warnings: ["blurry", "low_quality"] |
invoice.jpg | (empty) | invalid: type_mismatch, it is not a DNI |
In test mode the response depends on the file name, and the file must be a real JPEG, PNG, WEBP, HEIC or PDF (any renamed image works). It costs no credits and livemode is false. All names are listed in Test mode.
For the webhook, sign a body with signWebhook and post it to your server:
import { signWebhook } from "@constaia/sdk";
const body = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed", verdict: { status: "valid", reasons: [] } },
});
const headers = await signWebhook(body, process.env.CONSTAIA_WEBHOOK_SECRET!);
const res = await fetch("http://localhost:3000/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body,
});
console.log(res.status); // 200Production checklist
- Authentication and per-user rate limiting on
/api/verify-dni: every call spends credits. - Body limit of at least 20 MB in the server and the proxy (nginx
client_max_body_size 21m). - Timeouts of 60 s or more in proxy and load balancer; a synchronous analysis can take up to 30 s.
- PDFs over 30 pages or high volume:
async: true+ webhook, or batches. - Webhook dedupe in your database (unique constraint on
webhook-id), not in memory. -
ck_live_key only in the server environment. - Alert on
InsufficientCreditsErrorand thecredits.lowevent.
Next steps
Integrations
Integrate Constaia with any stack: JavaScript, PHP, Python, Go, Java, .NET, Ruby, no-code tools and AI agents. Full code with uploads, errors and webhooks.
Express
Validate ID cards and other documents in Express 5 with the Constaia SDK, in-memory multer uploads and a webhook verified on the raw body.