Constaia
Integraciones

Expo y React Native

Fotografía documentos en una app Expo o React Native con expo-image-picker, súbelos a tu backend con FormData y deja que el servidor llame a Constaia.

En esta guía una app de Expo hace una foto del documento, la sube a tu backend y muestra el veredicto. El backend (Node con Express) es quien llama a Constaia con la clave.

App Expo ──foto (multipart)──▶ Tu backend ──SDK──▶ api.constaia.com
         ◀──── veredicto ─────             ◀──────

La clave nunca va en la app

Todo lo que viaja en el bundle de una app se puede extraer, incluidas las variables EXPO_PUBLIC_*. No pongas ck_live_... ni ck_test_... en la app: la app solo conoce la URL de tu backend y el token de sesión de tu usuario.

El widget es solo web

@constaia/widget es un web component que necesita el DOM del navegador, así que no funciona en React Native. En la app, la captura la haces con expo-image-picker (o expo-camera) y el control de calidad lo hace Constaia: si la foto no se lee bien, el análisis vuelve con review y pides otra.

Requisitos

  • Expo SDK 52 o superior (o React Native con expo-image-picker 16+ instalado). En versiones anteriores, mediaTypes se escribe ImagePicker.MediaTypeOptions.Images.
  • Node ≥ 18 para el backend.
  • Una clave de test ck_test_... del panel.

1. Backend (Express)

npm i express multer @constaia/sdk
server/.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
server/server.mjs
import express from "express";
import multer from "multer";
import {
  APITimeoutError,
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  WebhookVerificationError,
} from "@constaia/sdk";
import { requireUser } from "./auth.mjs";
import { markEventProcessed, saveVerification, updateVerification } from "./verifications.mjs";

const constaia = new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const app = express();

app.post("/api/constaia", requireUser, upload.single("file"), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: { code: "file_required", message: "Falta la foto." } });
  }
  try {
    const analysis = await constaia.analyze(req.file.buffer, {
      filename: req.file.originalname,
      expect: ["passport", "es_dni", "es_nie"],
      checks: { notExpired: true, minAgeYears: 18 },
      language: "es",
      metadata: { user_id: String(res.locals.user.id) },
    });
    await saveVerification(res.locals.user.id, analysis);
    res.status(analysis.status === "completed" ? 200 : 202).json(analysis);
  } catch (err) {
    sendError(res, err);
  }
});

app.post("/api/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(err instanceof WebhookVerificationError ? 400 : 500).send("invalid webhook");
  }
  try {
    if (await markEventProcessed(req.get("webhook-id"))) {
      if (["analysis.completed", "analysis.review_required", "analysis.failed"].includes(event.type)) {
        await updateVerification(event.data);
      }
    }
    res.sendStatus(204);
  } catch (err) {
    console.error(err);
    res.sendStatus(500);
  }
});

app.use((err, _req, res, next) => {
  if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
    return res.status(413).json({ error: { code: "file_too_large", message: "La foto supera los 20 MB." } });
  }
  next(err);
});

function sendError(res, err) {
  if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
  else console.error(err);

  const reply = (status, code, message) => res.status(status).json({ error: { code, message } });
  if (err instanceof InvalidRequestError) return reply(err.status ?? 400, err.code ?? "invalid_request", err.message);
  if (err instanceof RateLimitError) {
    if (err.retryAfter) res.set("Retry-After", String(err.retryAfter));
    return reply(429, "rate_limited", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.");
  }
  if (err instanceof InsufficientCreditsError) return reply(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
  if (err instanceof AuthenticationError || err instanceof PermissionError) return reply(500, "server_misconfigured", "Error de configuración del servidor.");
  if (err instanceof APITimeoutError) return reply(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
  if (err instanceof ConstaiaError) return reply(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
  return reply(500, "internal_error", "Error inesperado.");
}

app.listen(3000, () => console.log("API en http://localhost:3000"));
node --env-file=.env server.mjs
  • requireUser (tuyo) valida el token de sesión que envía la app en Authorization y deja el usuario en res.locals.user. saveVerification(), markEventProcessed() y updateVerification() son tu base de datos.
  • El servidor decide expect y checks. La app no envía opciones: aunque alguien modifique la app o la petición, no puede cambiar qué se comprueba.
  • El webhook recibe con express.raw() el cuerpo crudo para verificar la firma. Te llega el resultado de los análisis que tardan más de 30 s (la ruta responde entonces 202 con status: "queued" o "processing"); avisa a la app con una notificación push o deja que consulte a tu backend. Más en Webhooks y en la guía de Express.
Error del SDKHTTP hacia la appQué significa
InvalidRequestErrorel mismo (400, 409, 413, 415, 422)Foto o petición no válidas (vacía, formato no admitido, ilegible…).
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. La app

Instalación y permisos

npx expo install expo-image-picker
app.json
{
  "expo": {
    "plugins": [
      [
        "expo-image-picker",
        {
          "cameraPermission": "Necesitamos la cámara para fotografiar tu documento.",
          "photosPermission": "Necesitamos acceder a tus fotos para que elijas la imagen del documento."
        }
      ]
    ]
  }
}
.env
EXPO_PUBLIC_API_URL=https://api.tu-dominio.com

La URL del backend sí puede ser pública; la clave de Constaia no.

Pantalla de verificación

app/verify.tsx
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import { ActivityIndicator, Alert, Button, Image, Text, View } from "react-native";
import { getSessionToken } from "@/lib/session";

type VerdictStatus = "valid" | "invalid" | "review";

interface AnalysisResponse {
  status: "queued" | "processing" | "completed" | "failed";
  verdict: { status: VerdictStatus; reasons: { severity: string; message: string }[] } | null;
  error?: { message: string };
}

type Outcome =
  | { kind: VerdictStatus; messages: string[] }
  | { kind: "pending" }
  | { kind: "error"; message: string };

const API_URL = process.env.EXPO_PUBLIC_API_URL;
const TEST_FILE_NAME: string | undefined = __DEV__ ? "dni_valid.jpg" : undefined;

export default function VerifyScreen() {
  const [photo, setPhoto] = useState<ImagePicker.ImagePickerAsset | null>(null);
  const [sending, setSending] = useState(false);
  const [outcome, setOutcome] = useState<Outcome | null>(null);

  async function takePhoto() {
    const permission = await ImagePicker.requestCameraPermissionsAsync();
    if (!permission.granted) {
      Alert.alert("Permiso necesario", "Activa el acceso a la cámara en los ajustes del teléfono.");
      return;
    }
    const picked = await ImagePicker.launchCameraAsync({ mediaTypes: ["images"], quality: 0.8 });
    if (picked.canceled) return;
    setOutcome(null);
    setPhoto(picked.assets[0]);
  }

  async function pickFromLibrary() {
    const picked = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
    if (picked.canceled) return;
    setOutcome(null);
    setPhoto(picked.assets[0]);
  }

  async function upload() {
    if (!photo) return;
    setSending(true);
    try {
      const form = new FormData();
      form.append("file", {
        uri: photo.uri,
        name: TEST_FILE_NAME ?? photo.fileName ?? "document.jpg",
        type: photo.mimeType ?? "image/jpeg",
      } as unknown as Blob);

      const res = await fetch(`${API_URL}/api/constaia`, {
        method: "POST",
        headers: { Authorization: `Bearer ${await getSessionToken()}` },
        body: form,
      });
      const body = (await res.json()) as AnalysisResponse;

      if (!res.ok) {
        setOutcome({ kind: "error", message: body.error?.message ?? "No se ha podido verificar el documento." });
      } else if (body.status !== "completed" || !body.verdict) {
        setOutcome({ kind: "pending" });
      } else {
        setOutcome({
          kind: body.verdict.status,
          messages: body.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
        });
      }
    } catch {
      setOutcome({ kind: "error", message: "Sin conexión. Inténtalo de nuevo." });
    } finally {
      setSending(false);
    }
  }

  return (
    <View style={{ flex: 1, padding: 16, gap: 12 }}>
      <Text style={{ fontSize: 20, fontWeight: "600" }}>Fotografía tu documento</Text>
      <Text>Sobre una superficie lisa, con buena luz y sin reflejos. Que se vea el documento entero.</Text>

      {photo && <Image source={{ uri: photo.uri }} style={{ width: "100%", aspectRatio: 1.586, borderRadius: 8 }} />}

      <Button title={photo ? "Repetir foto" : "Hacer foto"} onPress={takePhoto} disabled={sending} />
      <Button title="Elegir de la galería" onPress={pickFromLibrary} disabled={sending} />
      {photo && <Button title="Enviar" onPress={upload} disabled={sending} />}
      {sending && <ActivityIndicator />}

      {outcome?.kind === "valid" && <Text>Documento válido.</Text>}
      {outcome?.kind === "pending" && <Text>Lo estamos revisando. Te avisaremos al terminar.</Text>}
      {outcome?.kind === "review" && (
        <>
          <Text>No hemos podido leer bien la foto.</Text>
          {outcome.messages.map((m) => (
            <Text key={m}>{m}</Text>
          ))}
          <Button title="Hacer otra foto" onPress={takePhoto} />
        </>
      )}
      {outcome?.kind === "invalid" && (
        <>
          <Text>El documento no es válido:</Text>
          {outcome.messages.map((m) => (
            <Text key={m}>{m}</Text>
          ))}
          <Button title="Probar con otro documento" onPress={takePhoto} />
        </>
      )}
      {outcome?.kind === "error" && <Text>{outcome.message}</Text>}
    </View>
  );
}
  • En React Native, un archivo se añade a FormData como { uri, name, type }. No fijes Content-Type: fetch lo genera con el boundary del multipart.
  • getSessionToken() es tuya: el token de sesión de tu usuario, que tu backend valida.
  • review significa que la imagen no es fiable (borrosa, recortada, con reflejos…). Pide otra foto en lugar de rechazar al usuario. Si se repite, deriva a revisión manual.
  • invalid trae motivos concretos, como not_expired (caducado) o type_mismatch (otro tipo de documento).

Con expo-camera

Si prefieres una cámara integrada en la pantalla, expo-camera devuelve también una uri y el envío es el mismo:

const picture = await cameraRef.current?.takePictureAsync({ quality: 0.8 });
if (picture) form.append("file", { uri: picture.uri, name: "document.jpg", type: "image/jpeg" } as unknown as Blob);

Dos caras en un solo archivo

La API recibe un único archivo por análisis. Un pasaporte cabe en una foto, pero el DNI y el NIE tienen dos caras: si solo envías una, el análisis puede devolver el aviso side_missing y quedar en review. Para esos documentos, pide dos fotos y combínalas en tu backend en una sola imagen (una cara encima de la otra) o en un PDF de dos páginas antes de llamar a Constaia; sigue costando 1 crédito.

3. Probar en modo test

Con la clave ck_test_... en el backend no se gastan créditos y la respuesta depende del nombre del archivo. Las fotos de la cámara tienen nombres aleatorios, por eso en desarrollo (__DEV__) la pantalla envía la foto con el nombre de TEST_FILE_NAME. La foto es real, así que el tipo de archivo es válido. Cambia el nombre para probar cada caso:

TEST_FILE_NAMEverdict.statusQué debe hacer la app
dni_valid.jpgVálidoContinuar. Motivo not_expired (info): "Vigente hasta el 12/03/2031."
dni_expired.jpgNo válidoMostrar "Caducado el 15/06/2020." y pedir otro documento.
blurry.jpgRevisarPedir otra foto. warnings: blurry, low_quality.
passport.jpgVálidoPasaporte vigente hasta el 01/06/2032.

Más nombres en Modo test.

Producción

  • Clave solo en el servidor, nunca en la app ni en EXPO_PUBLIC_*.
  • Autentica y limita /api/constaia por usuario: cada llamada gasta créditos y una app es fácil de automatizar.
  • Tamaño: multer ya acepta 20 MB; sube también el límite de tu proxy (nginx: client_max_body_size 25m;). Con quality: 0.8 las fotos suelen quedar muy por debajo.
  • Tiempos: el análisis síncrono espera hasta 30 s. Da a la petición de la app y al proxy margen de sobra (60 s o más) y muestra un indicador de progreso.
  • Redes móviles: si la conexión falla a mitad de subida, deja que el usuario reintente; el SDK del backend ya reintenta sus propias llamadas sin cobrar dos veces.
  • Decide en el servidor: el siguiente paso del alta lee el resultado guardado en saveVerification() (o GET /v1/analyses/{id}), no lo que diga la app.

Siguientes pasos

En esta página