Constaia
Integrations

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.

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

This guide uses only Node.js and the SDK, with no framework. You will build:

  1. A script that analyses a local file and prints the verdict. Handy for batch jobs, cron or quick tests.
  2. A node:http server with POST /api/verify-dni (uploads from your site or app) and POST /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/node

The 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

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

scripts/verify.ts
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.jpg
Spanish 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-12

fromPath (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

src/constaia.ts
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.

src/server.ts
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.ts

Key points:

  • The server decides expect and checks. Don't accept those options from the client. Take the holder's name from the session rather than the form.
  • File keeps the original name, which decides the response in test mode.
  • Raw body in the webhook. readBody returns the exact bytes, which is what Constaia signs. Never verify over JSON.stringify(JSON.parse(body)).
  • 202. If the analysis doesn't finish within 30 s, the API returns it as queued or processing and the result arrives by webhook.
  • requestTimeout controls 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 errorWhenWhat your route returns
InvalidRequestErrorEmpty, 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
RateLimitErrorYou exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After429 with Retry-After
InsufficientCreditsErrorNo credits left (402)503 to the user and an alert for you: top up in the dashboard
AuthenticationError, PermissionErrorMissing, revoked or wrong key (401/403)500: it is your configuration problem, not the user's
APIError, APIConnectionError, APITimeoutErrorConstaia 5xx or network error, after retries are exhausted502 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
Filefull_nameResult
dni_valid.jpgMaría García Lópezvalid
dni_valid.jpgJuan Pérezinvalid: 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:

scripts/send-test-webhook.ts
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); // 200

Production 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 InsufficientCreditsError and the credits.low event.

Next steps

Nesta página