Constaia
Integrations

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.

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 SvelteKit (Svelte 5):

  • An endpoint src/routes/api/constaia/+server.ts that receives the file from the widget and calls Constaia with the JavaScript SDK.
  • A page with the widget <constaia-upload>, imported in onMount.
  • A variant without the widget using a form action.
  • A webhook src/routes/api/webhooks/constaia/+server.ts that verifies the signature with request.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/widget

Environment variables

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

src/lib/server/constaia.ts
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 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; only the language is used from options.

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

src/routes/verify/+page.svelte
<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().

src/routes/verify-form/+page.server.ts
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] });
    }
  },
};
src/routes/verify-form/+page.svelte
<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().

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

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: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.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 form action: every call spends credits.
  • Body size: adapter-node rejects bodies over 512 KB by default. Start the server with BODY_SIZE_LIMIT=25M (and raise your proxy limit too, for example client_max_body_size 25m; in nginx). On serverless adapters, check the platform limit and set the widget's max-size-mb accordingly.
  • 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

Nesta página