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-picker16+ instalado). En versiones anteriores,mediaTypesse escribeImagePicker.MediaTypeOptions.Images. - Node ≥ 18 para el backend.
- Una clave de test
ck_test_...del panel.
1. Backend (Express)
npm i express multer @constaia/sdkCONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...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.mjsrequireUser(tuyo) valida el token de sesión que envía la app enAuthorizationy deja el usuario enres.locals.user.saveVerification(),markEventProcessed()yupdateVerification()son tu base de datos.- El servidor decide
expectychecks. 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 constatus: "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 SDK | HTTP hacia la app | Qué significa |
|---|---|---|
InvalidRequestError | el mismo (400, 409, 413, 415, 422) | Foto o petición no válidas (vacía, formato no admitido, ilegible…). |
RateLimitError | 429 + Retry-After | Superaste las peticiones por segundo de tu clave. |
InsufficientCreditsError | 503 | Sin créditos: avisa a tu equipo. |
AuthenticationError, PermissionError | 500 | Clave ausente, revocada o incorrecta. |
APITimeoutError | 504 | El SDK agotó su timeout. |
APIError, APIConnectionError | 502 | Error 5xx de Constaia o de red. |
2. La app
Instalación y permisos
npx expo install expo-image-picker{
"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."
}
]
]
}
}EXPO_PUBLIC_API_URL=https://api.tu-dominio.comLa URL del backend sí puede ser pública; la clave de Constaia no.
Pantalla de verificación
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
FormDatacomo{ uri, name, type }. No fijesContent-Type:fetchlo genera con elboundarydel multipart. getSessionToken()es tuya: el token de sesión de tu usuario, que tu backend valida.reviewsignifica 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.invalidtrae motivos concretos, comonot_expired(caducado) otype_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_NAME | verdict.status | Qué debe hacer la app |
|---|---|---|
dni_valid.jpg | Válido | Continuar. Motivo not_expired (info): "Vigente hasta el 12/03/2031." |
dni_expired.jpg | No válido | Mostrar "Caducado el 15/06/2020." y pedir otro documento. |
blurry.jpg | Revisar | Pedir otra foto. warnings: blurry, low_quality. |
passport.jpg | Válido | Pasaporte 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/constaiapor 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;). Conquality: 0.8las 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()(oGET /v1/analyses/{id}), no lo que diga la app.