SvelteKit
Validate documents in SvelteKit with a +server.ts endpoint or a form action, $env/static/private, the widget loaded in onMount and a signed webhook.
In this guide you add Spanish ID card (DNI) verification to SvelteKit (Svelte 5):
- An endpoint
src/routes/api/constaia/+server.tsthat receives the file from the widget and calls Constaia with the JavaScript SDK. - A page with the widget
<constaia-upload>, imported inonMount. - A variant without the widget using a form action.
- A webhook
src/routes/api/webhooks/constaia/+server.tsthat verifies the signature withrequest.text().
The key is imported from $env/static/private in a $lib/server module: SvelteKit prevents that code from reaching the browser.
Requirements
- SvelteKit 2 with Svelte 5 and a server adapter (for example
@sveltejs/adapter-node). - A
ck_test_...test key from the dashboard.
Install
npm i @constaia/sdk @constaia/widgetEnvironment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...No PUBLIC_ prefix. $env/static/private inlines the value at build time; if you prefer reading it at runtime (one build for test and production), use $env/dynamic/private with env.CONSTAIA_API_KEY.
1. Client and errors
import { CONSTAIA_API_KEY } from "$env/static/private";
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
export const constaia = new Constaia({ apiKey: CONSTAIA_API_KEY });
export interface HttpError {
status: number;
body: { error: { code: string; message: string } };
retryAfter?: number;
}
function fail(status: number, code: string, message: string, retryAfter?: number): HttpError {
return { status, body: { error: { code, message } }, retryAfter };
}
export function toHttpError(err: unknown): HttpError {
if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
else console.error(err);
if (err instanceof InvalidRequestError) return fail(err.status ?? 400, err.code ?? "invalid_request", err.message);
if (err instanceof RateLimitError) {
return fail(429, "rate_limited", "Too many requests. Try again in a few seconds.", err.retryAfter);
}
if (err instanceof InsufficientCreditsError) {
return fail(503, "verification_unavailable", "Verification is not available right now.");
}
if (err instanceof AuthenticationError || err instanceof PermissionError) {
return fail(500, "server_misconfigured", "Server configuration error.");
}
if (err instanceof APITimeoutError) {
return fail(504, "timeout", "Verification took too long. Please try again.");
}
if (err instanceof ConstaiaError) {
return fail(502, "upstream_error", "The document could not be verified. Please try again.");
}
return fail(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";
}
}| 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; only the language is used from options.
import { json } from "@sveltejs/kit";
import { constaia, languageFrom, toHttpError } from "$lib/server/constaia";
import { saveVerification } from "$lib/server/verifications";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return 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 json({ error: { code: "file_required", message: "The file is missing." } }, { status: 400 });
}
try {
const analysis = await constaia.analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: languageFrom(form.get("options")),
metadata: { user_id: String(locals.user.id) },
});
await saveVerification(locals.user.id, analysis);
return json(analysis, { status: analysis.status === "completed" ? 200 : 202 });
} catch (err) {
const e = toHttpError(err);
const headers = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : undefined;
return json(e.body, { status: e.status, headers });
}
};locals.user is set by your hooks.server.ts (your session system) 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
@constaia/widget registers <constaia-upload> when imported. Import it in onMount so it only runs in the browser. The widget's event names contain a colon (constaia:result), so the safest option is to listen with bind:this and addEventListener instead of event attribute syntax.
<script lang="ts">
import { onMount } from "svelte";
import type { Analysis, ConstaiaUploadElement, WidgetErrorDetail } from "@constaia/widget";
let uploader: ConstaiaUploadElement | undefined = $state();
let verdict = $state<string | null>(null);
onMount(() => {
void import("@constaia/widget");
const onResult = (e: Event) => {
verdict = (e as CustomEvent<Analysis>).detail.verdict?.status ?? null;
};
const onError = (e: Event) => {
const { code, message } = (e as CustomEvent<WidgetErrorDetail>).detail;
console.warn(code, message);
};
uploader?.addEventListener("constaia:result", onResult);
uploader?.addEventListener("constaia:error", onError);
return () => {
uploader?.removeEventListener("constaia:result", onResult);
uploader?.removeEventListener("constaia:error", onError);
};
});
function retry() {
uploader?.reset();
verdict = null;
}
</script>
<h1>Verify your ID</h1>
<constaia-upload bind:this={uploader} endpoint="/api/constaia" document="es_dni" lang="en"></constaia-upload>
{#if verdict === "valid"}
<a href="/signup/details">Continue</a>
{:else if verdict === "review"}
<p>We could not read it well. Take another photo in good light, without glare.</p>
{:else if verdict === "invalid"}
<button type="button" onclick={retry}>Try another document</button>
{/if}With document="es_dni" the widget asks for both sides and merges them into one JPEG. On /signup/details, check in the server load what you stored in saveVerification(), not what the browser says.
Variant: form action
Without the widget, a form action receives the file with request.formData().
import { fail } from "@sveltejs/kit";
import { constaia, toHttpError } from "$lib/server/constaia";
import { saveVerification } from "$lib/server/verifications";
import type { Actions } from "./$types";
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) return fail(401, { messages: ["Please sign in to continue."] });
const file = (await request.formData()).get("file");
if (!(file instanceof File) || file.size === 0) {
return fail(400, { messages: ["Choose a photo or a PDF of the document."] });
}
try {
const analysis = await constaia.analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: "en",
metadata: { user_id: String(locals.user.id) },
});
await saveVerification(locals.user.id, analysis);
if (analysis.status !== "completed" || !analysis.verdict) return { status: "pending", messages: [] };
return {
status: analysis.verdict.status,
messages: analysis.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
};
} catch (err) {
const e = toHttpError(err);
return fail(e.status, { messages: [e.body.error.message] });
}
},
};<script lang="ts">
import { enhance } from "$app/forms";
let { form } = $props();
let sending = $state(false);
</script>
<form
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
<button disabled={sending}>{sending ? "Verifying…" : "Verify"}</button>
</form>
{#if form?.status === "valid"}
<p>Valid document.</p>
{:else if form?.status === "pending"}
<p>We are checking it. We will let you know when it is done.</p>
{/if}
{#each form?.messages ?? [] as message}
<p>{message}</p>
{/each}Here the user uploads a single file: for both sides of the ID, one image with both or a two-page PDF (1 credit).
4. Webhook
Read the raw body with request.text() before any request.json().
import { CONSTAIA_WEBHOOK_SECRET } from "$env/static/private";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { constaia } from "$lib/server/constaia";
import { markEventProcessed, updateVerification } from "$lib/server/verifications";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
const raw = await request.text();
let event: WebhookEvent;
try {
event = await constaia.webhooks.verify(raw, request.headers, CONSTAIA_WEBHOOK_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 });
};- 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;updateVerification()must be idempotent.
Register https://your-domain.com/api/webhooks/constaia in the dashboard or with constaia.webhookEndpoints.create() and store the secret. To test locally, sign an event with signWebhook:
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:5173/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/constaiaand the form action: every call spends credits. - Body size:
adapter-noderejects bodies over 512 KB by default. Start the server withBODY_SIZE_LIMIT=25M(and raise your proxy limit too, for exampleclient_max_body_size 25m;in nginx). On serverless adapters, check the platform limit and set the widget'smax-size-mbaccordingly. - Timeouts: a synchronous analysis waits up to 30 s and the SDK uses 60 s per attempt. Tune proxy timeouts or your functions' maximum duration.
- Live key only in production 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
Nuxt
Validate documents in Nuxt 3 and 4 with a Nitro server route, private runtimeConfig, the Vue widget in a client-only component and a signed webhook.
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.