Constaia
Integrations

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.

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 React Router v7 in framework mode (Remix's successor). The same pieces work for Remix v2 with minimal changes, listed at the end.

  • A resource route app/routes/api.constaia.ts whose action receives the file from the widget and calls Constaia with the JavaScript SDK.
  • A page with the widget through the React wrapper.
  • A variant without the widget: a route with <Form> and an action.
  • A webhook resource route that verifies the signature with request.text().

The key lives in a .server.ts module, which the bundler never includes in browser code.

Requirements

  • React Router v7 in framework mode (or Remix v2) 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_...

Load .env in your server (for example node --env-file=.env or dotenv). These variables are only read in .server.ts modules, loaders and actions.

1. Client and errors

app/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 errorResponse(err: unknown): Response {
  const e = toHttpError(err);
  const headers = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : undefined;
  return Response.json(e.body, { status: e.status, headers });
}

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

app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("verify", "routes/verify.tsx"),
  route("verify-form", "routes/verify-form.tsx"),
  route("api/constaia", "routes/api.constaia.ts"),
  route("api/webhooks/constaia", "routes/api.webhooks.constaia.ts"),
] satisfies RouteConfig;

3. Upload resource route

A route without a default component is a resource route: its action answers JSON directly. The widget sends file and options (JSON with expect and language); the server sets expect and checks and only takes the language from the client.

app/routes/api.constaia.ts
import { errorResponse, getConstaia, languageFrom } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/api.constaia";

export async function action({ request }: Route.ActionArgs) {
  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) {
    return errorResponse(err);
  }
}

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.

4. Page with the widget

The @constaia/widget/react wrapper registers the element only in the browser, so it works with SSR.

app/routes/verify.tsx
import { Link } from "react-router";
import { ConstaiaUpload, useConstaiaUpload } from "@constaia/widget/react";

export default function Verify() {
  const { ref, result, error, reset } = useConstaiaUpload();
  const verdict = result?.verdict?.status;

  return (
    <main>
      <h1>Verify your ID</h1>
      <ConstaiaUpload
        ref={ref}
        endpoint="/api/constaia"
        document="es_dni"
        lang="en"
        onError={(e) => console.warn(e.code, e.message)}
      />
      {verdict === "valid" && <Link to="/signup/details">Continue</Link>}
      {verdict === "review" && <p>We could not read it well. Take another photo in good light, without glare.</p>}
      {(verdict === "invalid" || error) && (
        <button type="button" onClick={reset}>
          Try another document
        </button>
      )}
    </main>
  );
}

With document="es_dni" the widget asks for both sides and merges them into one JPEG. In the loader of /signup/details, check what you stored in saveVerification(); the browser's verdict is not proof.

5. Variant: Form and action

app/routes/verify-form.tsx
import { data, Form, useNavigation } from "react-router";
import { getConstaia, toHttpError } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/verify-form";

export async function action({ request }: Route.ActionArgs) {
  const user = await getCurrentUser(request);
  if (!user) return data({ status: "error", messages: ["Please sign in to continue."] }, { status: 401 });

  const file = (await request.formData()).get("file");
  if (!(file instanceof File) || file.size === 0) {
    return data({ status: "error", messages: ["Choose a photo or a PDF of the document."] }, { status: 400 });
  }

  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) {
    const e = toHttpError(err);
    return data({ status: "error", messages: [e.body.error.message] }, { status: e.status });
  }
}

export default function VerifyForm({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";

  return (
    <main>
      <h1>Upload your ID</h1>
      <Form 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={sending}>
          {sending ? "Verifying…" : "Verify"}
        </button>
      </Form>
      {actionData?.status === "valid" && <p>Valid document.</p>}
      {actionData?.status === "pending" && <p>We are checking it. We will let you know when it is done.</p>}
      {actionData?.messages.map((m) => (
        <p key={m}>{m}</p>
      ))}
    </main>
  );
}

Here the user uploads a single file: for both sides of the ID, one image with both or a two-page PDF (1 credit).

6. Webhook

Another resource route. Read the raw body with request.text() and verify before parsing.

app/routes/api.webhooks.constaia.ts
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { getConstaia } from "~/lib/constaia.server";
import { markEventProcessed, updateVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/api.webhooks.constaia";

export async function action({ request }: Route.ActionArgs) {
  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 });
}
  • 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:

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

Remix v2

With Remix v2 and flat file routes you do not need app/routes.ts: app/routes/api.constaia.ts already answers at /api/constaia. Change the types and imports:

import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  // same body as above
}

In the component, read the result with useActionData<typeof action>() instead of the actionData prop, and use json() from @remix-run/node (or Response.json) instead of data().

7. 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: request.formData() loads the file into memory and the React Router server 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.
  • Timeouts: a synchronous analysis waits up to 30 s and the SDK uses 60 s per attempt. Tune proxy timeouts or your serverless 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

Sur cette page