SolidStart
Validate documents in SolidStart with an API route (APIEvent), a "use server" action, the widget as a web component and a signed webhook.
In this guide you add Spanish ID card (DNI) verification to SolidStart 1.x:
- An API route
src/routes/api/constaia.tsthat receives the file from the widget and calls Constaia with the JavaScript SDK. - A page with the widget
<constaia-upload>as a web component. - A variant without the widget using a
"use server"action anduseSubmission. - A webhook
src/routes/api/webhooks/constaia.tsthat verifies the signature withrequest.text().
The key is only read in server code (process.env), never in components.
Requirements
- SolidStart 1.x on Node ≥ 18.
- 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 VITE_ prefix: Vite only exposes variables with that prefix to the browser.
1. Client and errors
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
let client: Constaia | undefined;
export function getConstaia(): Constaia {
client ??= new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
return client;
}
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 API route
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 { APIEvent } from "@solidjs/start/server";
import { getConstaia, languageFrom, toHttpError } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
export async function POST({ request }: APIEvent) {
const user = await getCurrentUser(request);
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) {
const e = toHttpError(err);
const headers = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : undefined;
return Response.json(e.body, { status: e.status, headers });
}
}getCurrentUser() and saveVerification() are yours (your session and your database). 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 as a web component
Declare the element for TypeScript, import it in onMount (browser only) and listen to events with addEventListener: their names contain a colon (constaia:result).
import "solid-js";
declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
"constaia-upload": JSX.HTMLAttributes<HTMLElement> & {
endpoint?: string;
document?: string;
expect?: string;
lang?: string;
};
}
}
}import { createSignal, onCleanup, onMount, Show } from "solid-js";
import type { Analysis, ConstaiaUploadElement, WidgetErrorDetail } from "@constaia/widget";
export default function Verify() {
let uploader: ConstaiaUploadElement | undefined;
const [verdict, setVerdict] = createSignal<string | null>(null);
onMount(() => {
void import("@constaia/widget");
const onResult = (e: Event) => setVerdict((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);
onCleanup(() => {
uploader?.removeEventListener("constaia:result", onResult);
uploader?.removeEventListener("constaia:error", onError);
});
});
return (
<main>
<h1>Verify your ID</h1>
<constaia-upload ref={(el) => (uploader = el as ConstaiaUploadElement)} endpoint="/api/constaia" document="es_dni" lang="en" />
<Show when={verdict() === "valid"}>
<a href="/signup/details">Continue</a>
</Show>
<Show when={verdict() === "review"}>
<p>We could not read it well. Take another photo in good light, without glare.</p>
</Show>
<Show when={verdict() === "invalid"}>
<button type="button" onClick={() => { uploader?.reset(); setVerdict(null); }}>
Try another document
</button>
</Show>
</main>
);
}With document="es_dni" the widget asks for both sides and merges them into one JPEG. On /signup/details, check on the server what you stored in saveVerification().
Variant: "use server" action
Without the widget, a @solidjs/router action with "use server" receives the form's FormData.
import { action, useSubmission } from "@solidjs/router";
import { For, Show } from "solid-js";
import { getRequestEvent } from "solid-js/web";
const verifyDocument = action(async (formData: FormData) => {
"use server";
const { getConstaia, toHttpError } = await import("~/lib/constaia.server");
const { getCurrentUser } = await import("~/lib/auth.server");
const { saveVerification } = await import("~/lib/verifications.server");
const user = await getCurrentUser(getRequestEvent()!.request);
if (!user) return { status: "error", messages: ["Please sign in to continue."] };
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return { status: "error", messages: ["Choose a photo or a PDF of the document."] };
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: "en",
metadata: { user_id: String(user.id) },
});
await saveVerification(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) {
return { status: "error", messages: [toHttpError(err).body.error.message] };
}
}, "verify-document");
export default function VerifyForm() {
const submission = useSubmission(verifyDocument);
return (
<main>
<h1>Upload your ID</h1>
<form action={verifyDocument} method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
<button type="submit" disabled={submission.pending}>
{submission.pending ? "Verifying…" : "Verify"}
</button>
</form>
<Show when={submission.result?.status === "valid"}>
<p>Valid document.</p>
</Show>
<Show when={submission.result?.status === "pending"}>
<p>We are checking it. We will let you know when it is done.</p>
</Show>
<For each={submission.result?.messages ?? []}>{(m) => <p>{m}</p>}</For>
</main>
);
}The import() calls inside the "use server" function keep server code out of the client bundle. 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
import type { APIEvent } from "@solidjs/start/server";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { getConstaia } from "~/lib/constaia.server";
import { markEventProcessed, updateVerification } from "~/lib/verifications.server";
export async function POST({ request }: APIEvent) {
const secret = process.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:3000/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 action: every call spends credits. - Body size: allow at least 20 MB plus multipart overhead in your proxy or platform (nginx:
client_max_body_size 25m;) or set the widget'smax-size-mb. - 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 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
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.
Angular
Integrate Constaia in Angular 18+ with the custom element and CUSTOM_ELEMENTS_SCHEMA, or with HttpClient, always uploading to your backend (Express example).