Constaia
Integrations

SolidStart

Validate documents in SolidStart with an API route (APIEvent), a "use server" action, the widget as a web component and a signed webhook.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

In this guide you add Spanish ID card (DNI) verification to SolidStart 1.x:

  • An API route src/routes/api/constaia.ts that 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 and useSubmission.
  • A webhook src/routes/api/webhooks/constaia.ts that verifies the signature with request.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/widget

Environment variables

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

src/lib/constaia.server.ts
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 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 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.

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

src/constaia-upload.d.ts
import "solid-js";

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "constaia-upload": JSX.HTMLAttributes<HTMLElement> & {
        endpoint?: string;
        document?: string;
        expect?: string;
        lang?: string;
      };
    }
  }
}
src/routes/verify.tsx
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.

src/routes/verify-form.tsx
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

src/routes/api/webhooks/constaia.ts
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 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: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.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 and 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's max-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

Nesta página