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.
You will build an Express 5 server with two routes:
POST /api/verify-dni: receives a file, analyses it with Constaia and returns the verdict (valid,invalidorreview).POST /webhooks/constaia: receives Constaia events, verifies the signature on the raw body and drops duplicates.
The API key lives only on the server. The browser, a mobile app or the widget send the file to your route, never to Constaia.
Install
npm i express multer @constaia/sdk
npm i -D typescript tsx @types/express @types/multerRequirements: Node.js 20 or later, Express 5 and multer 2.
Environment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...CONSTAIA_API_KEY: create it in the dashboard → API keys. Start with ack_test_key (free, deterministic responses).CONSTAIA_WEBHOOK_SECRET: shown only once, when you create the webhook endpoint. See Webhooks.
Shared client and helpers
This module creates the client, trims the response the browser sees, maps SDK errors to HTTP responses and handles webhook events.
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
import express, { type NextFunction, type Request, type Response } from "express";
import multer from "multer";
import { WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024, files: 1 },
});
// The webhook goes BEFORE express.json(): it needs the unparsed body.
app.post(
"/webhooks/constaia",
express.raw({ type: "application/json", limit: "1mb" }),
async (req: Request, res: Response) => {
let event: WebhookEvent;
try {
event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof WebhookVerificationError) {
res.status(400).send("Invalid signature");
return;
}
throw error;
}
const webhookId = req.get("webhook-id")!;
if (alreadyProcessed(webhookId)) {
res.sendStatus(200);
return;
}
await handleEvent(event);
markProcessed(webhookId);
res.sendStatus(200);
},
);
app.use(express.json());
// Put your authentication here: only signed-in users should spend credits.
app.post("/api/verify-dni", upload.single("file"), async (req: Request, res: Response) => {
if (!req.file) {
res.status(400).json({ error: { code: "missing_file", message: "No file received." } });
return;
}
// Even better: take the name from the authenticated user, not from the form.
const fullName = String(req.body?.full_name ?? "").trim();
try {
const analysis = await constaia.analyze(req.file.buffer, {
filename: req.file.originalname,
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "en",
});
// Store analysis.id and analysis.verdict in your database.
res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
if (retryAfter) res.set("Retry-After", String(retryAfter));
res.status(status).json(body);
}
});
app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
if (error instanceof multer.MulterError && error.code === "LIMIT_FILE_SIZE") {
res.status(413).json({ error: { code: "file_too_large", message: "20 MB maximum." } });
return;
}
next(error);
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`http://localhost:${port}`));Start it with:
npx tsx --env-file=.env src/server.tsKey points:
- The server decides
expectandchecks. Never accept the expected type or the checks from the client: anyone could ask forgenericand skip validation. filename. When you pass aBuffer, give the original name. In test mode the name decides the response.storage: "none". The file is processed in memory and not stored. See Storage and privacy.- 202. If the analysis takes longer than 30 s, the API returns the analysis as
queuedorprocessingand the result arrives by webhook. That is why the route answers 202 in that case. - Express 5 forwards errors from
asynchandlers to the error middleware; you don't needexpress-async-errors.
What the browser receives
publicResult returns the shape the widget understands (verdict.status, verdict.reasons[].message, warnings) without exposing extracted data:
{
"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": []
}With <constaia-upload endpoint="/api/verify-dni"> you need no more client code. The widget also sends an options field with expect as a hint; this route ignores it on purpose.
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.
Webhook
- Raw body.
express.raw()leavesreq.bodyas aBuffer. Ifexpress.json()parses it first, the re-serialised JSON does not match byte for byte and the signature fails. That is why the webhook route is registered first. - Dedupe.
webhook-idis stable across retries. Store it with a unique constraint and answer 200 if you have seen it. - Answer fast. Constaia expects a 2xx within 15 s. If processing is heavy, queue it and answer.
- No session. If you have a global auth middleware, exclude
/webhooks/constaia: Constaia sends no cookies or tokens, it authenticates with the signature.
Register the endpoint in the dashboard or with the SDK:
import { Constaia } from "@constaia/sdk";
const constaia = new Constaia();
const endpoint = await constaia.webhookEndpoints.create({
url: "https://your-domain.com/webhooks/constaia",
events: ["analysis.completed", "analysis.failed", "analysis.review_required", "credits.low"],
});
console.log(endpoint.secret); // whsec_…: put it in CONSTAIA_WEBHOOK_SECRET, it is only shown nowTest 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.
To test the webhook locally without exposing your machine, sign a body with signWebhook and post it to your route:
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(for exampleexpress-rate-limit). Every call spends credits. - Body limit of at least 20 MB in multer, in your proxy (nginx
client_max_body_size 21m) and in your load balancer. - Timeouts of 60 s or more in the proxy and the server: a synchronous analysis can take up to 30 s.
- Long PDFs (more than 30 pages) or high volumes:
async: trueand the result by webhook. See Batches. - Store
analysis.idwith your record; extracted data only if you need it. -
ck_live_key only in the server's environment variables, never in the repository. - Alert when you get
InsufficientCreditsErroror thecredits.lowevent.