Astro
Validate documents in Astro with an SSR adapter, a src/pages/api/constaia.ts endpoint, the widget loaded through a script tag and a signed webhook.
In this guide you add Spanish ID card (DNI) verification to an Astro site:
- An endpoint
src/pages/api/constaia.tsthat receives the file and calls Constaia with the JavaScript SDK. - An
.astropage with the widget<constaia-upload>loaded through a<script>tag. - A webhook
src/pages/api/webhooks/constaia.tsthat verifies the signature withrequest.text().
Endpoints that receive requests need on-demand rendering, so you need an SSR adapter. A fully static site cannot hold the key: in that case, point the widget at a separate backend (for example Express).
Requirements
- Astro 4 or 5 with a server adapter. This guide uses
@astrojs/node. - A
ck_test_...test key from the dashboard.
Install
npx astro add node
npm i @constaia/sdk @constaia/widgetimport { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
});If you prefer to keep the site static except for these endpoints, leave the default output and add export const prerender = false; to each endpoint (it is already in the code below).
Environment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...No PUBLIC_ prefix: Astro only exposes variables with that prefix to the browser. In development, import.meta.env reads .env. In production with the Node adapter, process environment variables are read with process.env; the helper below tries both.
1. Client and errors
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
export function env(name: "CONSTAIA_API_KEY" | "CONSTAIA_WEBHOOK_SECRET"): string | undefined {
return import.meta.env[name] ?? process.env[name];
}
let client: Constaia | undefined;
export function getConstaia(): Constaia {
client ??= new Constaia({ apiKey: env("CONSTAIA_API_KEY") });
return client;
}
export function errorResponse(err: unknown): Response {
if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
else console.error(err);
const reply = (status: number, code: string, message: string, headers?: Record<string, string>) =>
Response.json({ error: { code, message } }, { status, headers });
if (err instanceof InvalidRequestError) return reply(err.status ?? 400, err.code ?? "invalid_request", err.message);
if (err instanceof RateLimitError) {
const headers = err.retryAfter ? { "Retry-After": String(err.retryAfter) } : undefined;
return reply(429, "rate_limited", "Too many requests. Try again in a few seconds.", headers);
}
if (err instanceof InsufficientCreditsError) {
return reply(503, "verification_unavailable", "Verification is not available right now.");
}
if (err instanceof AuthenticationError || err instanceof PermissionError) {
return reply(500, "server_misconfigured", "Server configuration error.");
}
if (err instanceof APITimeoutError) {
return reply(504, "timeout", "Verification took too long. Please try again.");
}
if (err instanceof ConstaiaError) {
return reply(502, "upstream_error", "The document could not be verified. Please try again.");
}
return reply(500, "internal_error", "Unexpected error.");
}
export function languageFrom(raw: FormDataEntryValue | null): "es" | "en" | "pt" | "fr" {
try {
const value = JSON.parse(String(raw ?? "{}")).language;
return ["es", "en", "pt", "fr"].includes(value) ? value : "en";
} catch {
return "en";
}
}Import this module only from endpoints and page frontmatter, never from a client <script>.
| SDK error | HTTP to your frontend | Meaning |
|---|---|---|
InvalidRequestError | the same (400, 409, 413, 415, 422) | Invalid file or request. The user can fix it. |
RateLimitError | 429 + Retry-After | You exceeded your key's requests per second. |
InsufficientCreditsError | 503 | No credits: alert your team. |
AuthenticationError, PermissionError | 500 | Missing, revoked or wrong key. |
APITimeoutError | 504 | The SDK hit its timeout. |
APIError, APIConnectionError | 502 | Constaia 5xx or network error. |
2. Upload endpoint
The widget sends file and options (JSON with expect and language). The server sets expect and checks; it only takes the language from options.
import type { APIRoute } from "astro";
import { errorResponse, getConstaia, languageFrom } from "../../lib/constaia";
import { saveVerification } from "../../lib/verifications";
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return Response.json({ error: { code: "unauthorized", message: "Please sign in to continue." } }, { status: 401 });
}
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return Response.json({ error: { code: "file_required", message: "The file is missing." } }, { status: 400 });
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: languageFrom(form.get("options")),
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
return Response.json(analysis, { status: analysis.status === "completed" ? 200 : 202 });
} catch (err) {
return errorResponse(err);
}
};locals.user is set by your session middleware (src/middleware.ts) and saveVerification() is your database access. If the analysis takes longer than 30 s, Constaia returns 202 with status: "queued" or "processing"; the widget shows a "queued" notice and the result arrives through the webhook.
3. The widget with a script tag
Astro bundles page <script> tags as browser modules, so you can import the npm package. Listen to the widget's events with addEventListener.
---
export const prerender = false;
---
<html lang="en">
<body>
<main>
<h1>Verify your ID</h1>
<constaia-upload endpoint="/api/constaia" document="es_dni" lang="en"></constaia-upload>
<p id="next" hidden><a href="/signup/details">Continue</a></p>
</main>
<script>
import "@constaia/widget";
import type { Analysis } from "@constaia/widget";
const uploader = document.querySelector("constaia-upload");
const next = document.getElementById("next");
uploader?.addEventListener("constaia:result", (event) => {
const analysis = (event as CustomEvent<Analysis>).detail;
if (next) next.hidden = analysis.verdict?.status !== "valid";
});
uploader?.addEventListener("constaia:error", (event) => {
const { code, message } = (event as CustomEvent<{ code: string; message: string }>).detail;
console.warn(code, message);
});
</script>
</body>
</html>If you do not want to go through the bundler, load the widget from the CDN with is:inline:
<script is:inline type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>With document="es_dni" the widget asks for both sides and merges them into one JPEG. The "Continue" link is UI only: on /signup/details, check on the server what you stored in saveVerification().
4. Webhook
import type { APIRoute } from "astro";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { env, getConstaia } from "../../../lib/constaia";
import { markEventProcessed, updateVerification } from "../../../lib/verifications";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const secret = env("CONSTAIA_WEBHOOK_SECRET");
if (!secret) return new Response("CONSTAIA_WEBHOOK_SECRET is not set", { status: 500 });
const raw = await request.text();
let event: WebhookEvent;
try {
event = await getConstaia().webhooks.verify(raw, request.headers, secret);
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
throw err;
}
if (await markEventProcessed(request.headers.get("webhook-id") ?? "")) {
switch (event.type) {
case "analysis.completed":
case "analysis.review_required":
case "analysis.failed":
await updateVerification(event.data as Analysis);
break;
}
}
return new Response(null, { status: 204 });
};- Verify against the raw body (
request.text()), never against re-serialised JSON. - Answer within 15 s; if the work is heavy, enqueue it.
markEventProcessed()(yours) stores thewebhook-idunder a unique key: retries repeat the same id.analysis.review_requiredarrives in addition toanalysis.completed.
Register https://your-domain.com/api/webhooks/constaia in the dashboard or with constaia.webhookEndpoints.create() and store the secret. To test locally:
import { signWebhook } from "@constaia/sdk";
const payload = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed" },
});
const headers = await signWebhook(payload, process.env.CONSTAIA_WEBHOOK_SECRET);
const res = await fetch("http://localhost:4321/api/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: payload,
});
console.log(res.status);node --env-file=.env scripts/send-test-webhook.mjs5. Test mode
With ck_test_... the result depends on the file name, which must be a real image or PDF. The widget merges both sides into a JPEG named after the front.
| File | verdict.status | Main reason |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Valid until 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): expired on 15/06/2020 |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
photo.jpg (any other name) | No válido | type_mismatch: generic is detected |
More names in Test mode.
Production
- Authenticate and rate-limit
/api/constaiain your middleware: every call spends credits. - Body size: the Node adapter sets no limit of its own, but your proxy or platform may. Allow at least 20 MB plus multipart overhead (nginx:
client_max_body_size 25m;) or set the widget'smax-size-mb. On serverless adapters (Vercel, Netlify), check their body and duration limits. - Timeouts: a synchronous analysis waits up to 30 s and the SDK uses 60 s per attempt. Tune proxy or function timeouts.
- Live key only in the production environment and a webhook endpoint created with the live key.
- Decide on the server with the stored result or
GET /v1/analyses/{id}. - Review Rate limits and Errors.
Next steps
Remix and React Router
Validate documents in Remix and React Router v7 framework mode with an action reading request.formData(), the React widget and a webhook resource route.
SolidStart
Validate documents in SolidStart with an API route (APIEvent), a "use server" action, the widget as a web component and a signed webhook.