Constaia
Integraciones

Remix y React Router

Valida documentos en Remix y React Router v7 (modo framework) con una action que lee request.formData(), el widget de React y una resource route de webhook.

En esta guía montas la verificación de un DNI en React Router v7 en modo framework (el sucesor de Remix). Las mismas piezas sirven para Remix v2 con cambios mínimos, indicados al final.

  • Una resource route app/routes/api.constaia.ts cuya action recibe el archivo del widget y llama a Constaia con el SDK de JavaScript.
  • Una página con el widget mediante el wrapper de React.
  • Una variante sin widget: una ruta con <Form> y action.
  • Una resource route de webhook que verifica la firma con request.text().

La clave vive en un módulo .server.ts, que el bundler nunca incluye en el código del navegador.

Requisitos

  • React Router v7 en modo framework (o Remix v2) sobre Node ≥ 18.
  • Una clave de test ck_test_... del panel.

Instalación

npm i @constaia/sdk @constaia/widget

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Carga el .env con tu servidor (por ejemplo node --env-file=.env o dotenv). Estas variables solo se leen en módulos .server.ts, loaders y actions.

1. Cliente y errores

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", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.", err.retryAfter);
  }
  if (err instanceof InsufficientCreditsError) {
    return fail(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
  }
  if (err instanceof AuthenticationError || err instanceof PermissionError) {
    return fail(500, "server_misconfigured", "Error de configuración del servidor.");
  }
  if (err instanceof APITimeoutError) {
    return fail(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
  }
  if (err instanceof ConstaiaError) {
    return fail(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
  }
  return fail(500, "internal_error", "Error inesperado.");
}

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 : "es";
  } catch {
    return "es";
  }
}
Error del SDKHTTP hacia tu frontendQué significa
InvalidRequestErrorel mismo (400, 409, 413, 415, 422)Archivo o petición no válidos. El usuario puede corregirlo.
RateLimitError429 + Retry-AfterSuperaste las peticiones por segundo de tu clave.
InsufficientCreditsError503Sin créditos: avisa a tu equipo.
AuthenticationError, PermissionError500Clave ausente, revocada o incorrecta.
APITimeoutError504El SDK agotó su timeout.
APIError, APIConnectionError502Error 5xx de Constaia o de red.

2. Rutas

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. Resource route de subida

Una ruta sin componente por defecto es una resource route: su action responde JSON directamente. El widget envía file y options (JSON con expect y language); el servidor fija expect y checks y solo toma el idioma del cliente.

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: "Inicia sesión para continuar." } }, { 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: "Falta el archivo." } }, { 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() y saveVerification() son tuyas (tu sesión y tu base de datos). Si el análisis tarda más de 30 s, Constaia responde 202 con status: "queued" o "processing"; el widget muestra un aviso de "en cola" y el resultado llega por el webhook.

4. Página con el widget

El wrapper @constaia/widget/react registra el elemento solo en el navegador, así que funciona con 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>Verifica tu DNI</h1>
      <ConstaiaUpload
        ref={ref}
        endpoint="/api/constaia"
        document="es_dni"
        lang="es"
        onError={(e) => console.warn(e.code, e.message)}
      />
      {verdict === "valid" && <Link to="/registro/datos">Continuar</Link>}
      {verdict === "review" && <p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>}
      {(verdict === "invalid" || error) && (
        <button type="button" onClick={reset}>
          Probar con otro documento
        </button>
      )}
    </main>
  );
}

Con document="es_dni" el widget pide las dos caras y las une en un JPEG. En el loader de /registro/datos, comprueba lo que guardaste en saveVerification(); el veredicto del navegador no es una prueba.

5. Variante: Form y 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: ["Inicia sesión para continuar."] }, { status: 401 });

  const file = (await request.formData()).get("file");
  if (!(file instanceof File) || file.size === 0) {
    return data({ status: "error", messages: ["Selecciona una foto o un PDF del documento."] }, { status: 400 });
  }

  try {
    const analysis = await getConstaia().analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, minAgeYears: 18 },
      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>Sube tu DNI</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 ? "Verificando…" : "Verificar"}
        </button>
      </Form>
      {actionData?.status === "valid" && <p>Documento válido.</p>}
      {actionData?.status === "pending" && <p>Lo estamos revisando. Te avisaremos al terminar.</p>}
      {actionData?.messages.map((m) => (
        <p key={m}>{m}</p>
      ))}
    </main>
  );
}

Aquí el usuario sube un único archivo: para las dos caras del DNI, una imagen con ambas o un PDF de dos páginas (1 crédito).

6. Webhook

Otra resource route. Lee el cuerpo crudo con request.text() y verifica antes de parsear.

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 });
}
  • Responde en menos de 15 s; si el trabajo es pesado, encólalo.
  • markEventProcessed() (tuya) guarda el webhook-id con clave única: los reintentos repiten el mismo id.
  • analysis.review_required llega además de analysis.completed; updateVerification() debe ser idempotente.

Registra https://tu-dominio.com/api/webhooks/constaia en el panel o con constaia.webhookEndpoints.create() y guarda el secret. Para probar en local:

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

Con Remix v2 y las rutas planas por convención no necesitas app/routes.ts: app/routes/api.constaia.ts ya responde en /api/constaia. Cambia los tipos e imports:

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

export async function action({ request }: ActionFunctionArgs) {
  // mismo cuerpo que arriba
}

En el componente, lee el resultado con useActionData<typeof action>() en lugar de la prop actionData, y usa json() de @remix-run/node (o Response.json) en lugar de data().

7. Probar en modo test

Con ck_test_... el resultado depende del nombre del archivo, que debe ser una imagen o PDF real. El widget une las dos caras en un JPEG con el nombre del anverso.

Archivoverdict.statusMotivo principal
dni_valid.jpgVálidonot_expired (info): "Vigente hasta el 12/03/2031."
dni_expired.jpgNo válidonot_expired (error): "Caducado el 15/06/2020."
blurry.jpgRevisarlow_quality (warning); warnings: blurry, low_quality
foto.jpg (otro nombre)No válidotype_mismatch: se detecta generic

Más nombres en Modo test.

Producción

  • Autentica y limita /api/constaia y la action del formulario: cada llamada gasta créditos.
  • Tamaño del cuerpo: request.formData() carga el archivo en memoria y el servidor de React Router no impone un límite propio, pero tu proxy o plataforma sí puede. Permite al menos 20 MB más el overhead del multipart (nginx: client_max_body_size 25m;) o ajusta max-size-mb en el widget.
  • Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s por intento. Ajusta los timeouts del proxy o la duración máxima de tus funciones serverless.
  • Clave live solo en producción y un endpoint de webhook creado con la clave live.
  • Decide en el servidor con el resultado guardado o GET /v1/analyses/{id}.
  • Revisa Límites de uso y Errores.

Siguientes pasos

En esta página