Constaia
Integrations

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.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

In this guide you add Spanish ID card (DNI) verification to an Astro site:

  • An endpoint src/pages/api/constaia.ts that receives the file and calls Constaia with the JavaScript SDK.
  • An .astro page with the widget <constaia-upload> loaded through a <script> tag.
  • A webhook src/pages/api/webhooks/constaia.ts that verifies the signature with request.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/widget
astro.config.mjs
import { 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

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

src/lib/constaia.ts
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 errorHTTP to your frontendMeaning
InvalidRequestErrorthe same (400, 409, 413, 415, 422)Invalid file or request. The user can fix it.
RateLimitError429 + Retry-AfterYou exceeded your key's requests per second.
InsufficientCreditsError503No credits: alert your team.
AuthenticationError, PermissionError500Missing, revoked or wrong key.
APITimeoutError504The SDK hit its timeout.
APIError, APIConnectionError502Constaia 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.

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

src/pages/verify.astro
---
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

src/pages/api/webhooks/constaia.ts
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 the webhook-id under a unique key: retries repeat the same id. analysis.review_required arrives in addition to analysis.completed.

Register https://your-domain.com/api/webhooks/constaia in the dashboard or with constaia.webhookEndpoints.create() and store the secret. To test locally:

scripts/send-test-webhook.mjs
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.mjs

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

Fileverdict.statusMain reason
dni_valid.jpgVálidonot_expired (info): "Valid until 12/03/2031."
dni_expired.jpgNo válidonot_expired (error): expired on 15/06/2020
blurry.jpgRevisarlow_quality (warning); warnings: blurry, low_quality
photo.jpg (any other name)No válidotype_mismatch: generic is detected

More names in Test mode.

Production

  • Authenticate and rate-limit /api/constaia in 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's max-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

Sur cette page