Deno
Use Constaia on Deno 2 with npm:@constaia/sdk: a script with minimal permissions and a Deno.serve server with uploads and a verified webhook.
You will build with Deno 2 and the SDK:
- A script that analyses a local file.
- A
Deno.serveserver withPOST /api/verify-dni(upload) andPOST /webhooks/constaia(signed events).
The SDK is imported with the npm: specifier and only uses fetch, FormData and WebCrypto. If you prefer a router, the Hono guide includes a Deno entry point.
Install
Nothing to install: import npm:@constaia/sdk and Deno downloads it the first time. To pin the version in deno.json:
deno add npm:@constaia/sdkThen you can import "@constaia/sdk" without the prefix. This guide uses npm:@constaia/sdk so the files work with no configuration.
Environment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Pass the key to the client with Deno.env.get and run with --env-file=.env --allow-env. Create the key in the dashboard → API keys; start with a ck_test_ key (Test mode).
1. Script
import { Constaia, ConstaiaError } from "npm:@constaia/sdk";
const constaia = new Constaia({ apiKey: Deno.env.get("CONSTAIA_API_KEY") });
const path = Deno.args[0] ?? "dni_valid.jpg";
const file = new File([await Deno.readFile(path)], path.split("/").pop()!);
try {
const analysis = await constaia.analyze(file, {
expect: "es_dni",
checks: { notExpired: true },
language: "en",
});
console.log(analysis.verdict?.status, analysis.verdict?.reasons.map((r) => r.message));
console.log(analysis.fields.document_number?.value);
} catch (error) {
if (!(error instanceof ConstaiaError)) throw error;
console.error(error.name, error.code, error.message, error.requestId);
Deno.exitCode = 1;
}deno run --allow-net --allow-env --allow-read --env-file=.env scripts/verify.ts dni_valid.jpgvalid [ "The document is Spanish ID card (DNI).", "Valid until 12/03/2031." ]
12345678Z2. Server
Shared helpers
import {
AuthenticationError,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
type Analysis,
type WebhookEvent,
} from "npm:@constaia/sdk";
// 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 };
}
const httpError = (status: number, code: string, message: string, retryAfter?: number) =>
Response.json(
{ error: { code, message } },
{ status, headers: retryAfter ? { "Retry-After": String(retryAfter) } : undefined },
);
export function errorResponse(error: unknown): Response {
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 { Constaia, WebhookVerificationError } from "npm:@constaia/sdk";
import { alreadyProcessed, errorResponse, handleEvent, markProcessed, publicResult } from "./constaia.ts";
const constaia = new Constaia({ apiKey: Deno.env.get("CONSTAIA_API_KEY") });
const webhookSecret = Deno.env.get("CONSTAIA_WEBHOOK_SECRET")!;
const MAX_UPLOAD = 21 * 1024 * 1024;
// Put your authentication here: only signed-in users should spend credits.
async function verifyDni(req: Request): Promise<Response> {
if (Number(req.headers.get("content-length") ?? 0) > MAX_UPLOAD) {
return Response.json({ error: { code: "file_too_large", message: "20 MB maximum." } }, { status: 413 });
}
const form = await req.formData().catch(() => null);
const file = form?.get("file");
if (!(file instanceof File) || file.size === 0) {
return Response.json({ error: { code: "missing_file", message: "No file received." } }, { status: 400 });
}
// Even better: the authenticated user's name, not the form's.
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.
return Response.json(publicResult(analysis), { status: analysis.status === "completed" ? 200 : 202 });
} catch (error) {
return errorResponse(error);
}
}
async function webhook(req: Request): Promise<Response> {
const rawBody = await req.text();
let event;
try {
event = await constaia.webhooks.verify(rawBody, req.headers, webhookSecret);
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
throw error;
}
const webhookId = req.headers.get("webhook-id")!;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
return new Response(null, { status: 204 });
}
Deno.serve({ port: 8000 }, (req) => {
const { pathname } = new URL(req.url);
if (req.method === "POST" && pathname === "/api/verify-dni") return verifyDni(req);
if (req.method === "POST" && pathname === "/webhooks/constaia") return webhook(req);
return new Response("Not found", { status: 404 });
});deno run --allow-net --allow-env --env-file=.env src/server.tsKey points:
- Permissions. The server only needs network and environment. To narrow them further:
--allow-net=api.constaia.com,0.0.0.0:8000. - The
Filename is sent to Constaia; in test mode it decides the response. - Raw body.
req.text()returns the bytes Constaia signs. Don't usereq.json()in the webhook. - The server decides
expectandchecks, never the client. 202 if the analysis is stillqueuedorprocessingafter 30 s. - Deno Deploy. The same
src/server.tsworks; setCONSTAIA_API_KEYandCONSTAIA_WEBHOOK_SECRETin the project's environment variables and check its documentation for request size and duration limits.
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": []
}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:8000/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:
import { signWebhook } from "npm:@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, Deno.env.get("CONSTAIA_WEBHOOK_SECRET")!);
const res = await fetch("http://localhost:8000/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body,
});
console.log(res.status); // 204Production checklist
- Authentication and per-user rate limiting on
/api/verify-dni: every call spends credits. - 20 MB upload limit in your code and in the proxy or platform.
- Timeouts of 60 s or more in proxy and platform; a synchronous analysis can take up to 30 s.
- PDFs over 30 pages or high volume:
async: true+ webhook, or batches. - Persistent webhook dedupe (for example Deno KV or your database).
-
ck_live_key only in the server environment. - Alert on
InsufficientCreditsErrorand thecredits.lowevent.