Electron
Integra Constaia en una app de Electron sin empaquetar la clave: el renderer pide el documento por IPC y el proceso main lo sube a tu backend.
Una app de Electron es un cliente: se instala en el ordenador del usuario, igual que una web o una app móvil. Por eso la clave de Constaia no puede ir dentro, tampoco en el proceso main. El flujo correcto:
- El renderer pide al proceso main, por IPC, que valide un documento.
- El main abre el diálogo de fichero, lo lee y lo sube a tu backend con el token de sesión del usuario.
- Tu backend llama a Constaia con la clave y devuelve el veredicto.
Por qué la clave no puede ir en la app
- El paquete se puede abrir.
app.asares un archivo sin cifrar; cualquiera lo extrae connpx @electron/asar extract. Ofuscar el código o guardar la clave en variables de entorno de compilación no cambia nada: acaba en el binario. - Una clave filtrada da acceso a toda tu cuenta. Con ella se gastan tus créditos y se pueden leer los análisis guardados de todos tus usuarios con
GET /v1/analyses, incluidos los datos extraídos. - No se puede revocar solo para un usuario. Tendrías que rotarla y publicar una versión nueva de la app.
- El SDK lo impide en el renderer: en un contexto con
windowydocument,new Constaia()con una claveck_lanza el errorsecret_key_in_browser.
Próximamente: claves publicables
Las claves publicables (seguras para clientes) y los enlaces de verificación (POST /v1/verification-links) todavía no están disponibles. Hoy, todas las llamadas a Constaia salen de tu servidor.
Backend
Necesitas una ruta como POST /api/verify-dni que exija la sesión del usuario, decida expect y checks y llame a Constaia. Sigue la guía de tu stack: Node.js, Express, Fastify, NestJS, Hono o AWS Lambda. La clave (CONSTAIA_API_KEY) y el secreto del webhook (CONSTAIA_WEBHOOK_SECRET) viven solo allí.
Proceso main
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
// TU backend, nunca api.constaia.com.
const API_BASE = "https://api.tu-app.com";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".heic": "image/heic",
".pdf": "application/pdf",
};
ipcMain.handle("document:verify", async (event, sessionToken: string) => {
const win = BrowserWindow.fromWebContents(event.sender)!;
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters: [{ name: "Documentos", extensions: ["jpg", "jpeg", "png", "webp", "heic", "pdf"] }],
});
if (canceled || !filePaths[0]) return { canceled: true };
const path = filePaths[0];
const data = await readFile(path);
if (data.byteLength > 20 * 1024 * 1024) {
return { error: { code: "file_too_large", message: "Máximo 20 MB." } };
}
const form = new FormData();
form.append("file", new Blob([data], { type: MIME[extname(path).toLowerCase()] }), basename(path));
try {
const res = await fetch(`${API_BASE}/api/verify-dni`, {
method: "POST",
headers: { authorization: `Bearer ${sessionToken}` },
body: form,
signal: AbortSignal.timeout(90_000),
});
const body = await res.json().catch(() => null);
if (!res.ok) return { error: body?.error ?? { code: "http_error", message: `HTTP ${res.status}` } };
return { result: body };
} catch {
return { error: { code: "network_error", message: "No hay conexión con el servidor." } };
}
});
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 700,
webPreferences: { preload: join(__dirname, "preload.js"), contextIsolation: true, sandbox: true },
});
win.loadFile("index.html");
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});- El diálogo lo abre el main: el renderer no puede pedir que se suba una ruta arbitraria del disco.
basename(path)conserva el nombre original, que tu backend reenvía a Constaia (en modo test decide la respuesta).- El timeout de 90 s cubre un análisis síncrono de hasta 30 s más la subida.
Preload
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("documents", {
verify: (sessionToken: string) => ipcRenderer.invoke("document:verify", sessionToken),
});Solo se expone una función concreta, no ipcRenderer entero.
Renderer
type Verdict = { status: "valid" | "invalid" | "review"; reasons: { code: string; message: string }[] };
type VerifyResponse =
| { canceled: true }
| { result: { id: string; status: string; verdict: Verdict | null; warnings: string[] } }
| { error: { code: string; message: string } };
declare global {
interface Window {
documents: { verify(sessionToken: string): Promise<VerifyResponse> };
}
}
declare function getSessionToken(): Promise<string>; // tu login
const button = document.querySelector<HTMLButtonElement>("#verify")!;
const output = document.querySelector<HTMLDivElement>("#result")!;
button.addEventListener("click", async () => {
button.disabled = true;
output.textContent = "Analizando…";
// El token de sesión de TU backend, obtenido en tu pantalla de login.
const response = await window.documents.verify(await getSessionToken());
button.disabled = false;
if ("canceled" in response) {
output.textContent = "";
} else if ("error" in response) {
output.textContent = response.error.message;
} else {
const verdict = response.result.verdict;
const labels = { valid: "Documento válido", invalid: "Documento no válido", review: "Lo revisaremos manualmente" };
output.textContent = verdict
? `${labels[verdict.status]}. ${verdict.reasons.map((r) => r.message).join(" ")}`
: "Análisis en curso; te avisaremos cuando termine.";
}
});
export {};getSessionToken() es tu función de autenticación. Si prefieres una interfaz ya hecha, el widget funciona dentro del renderer: <constaia-upload endpoint="https://api.tu-app.com/api/verify-dni"> con el atributo headers para el token o with-credentials para cookies. Tu backend debe permitir CORS desde el origen de la app.
Probar en modo test
- Arranca tu backend con
CONSTAIA_API_KEY=ck_test_.... - En la app, pulsa el botón y elige un fichero llamado
dni_valid.jpg: verás "Documento válido". - Repite con
dni_expired.jpg(invalid, motivonot_expired) yblurry.jpg(review, motivolow_quality).
Cualquier JPEG o PNG real renombrado sirve. Más nombres en Modo test.
Errores
Tu backend traduce los errores del SDK (sin créditos, límite de peticiones, fichero ilegible…) a respuestas HTTP con { "error": { "code", "message" } }. El main las pasa tal cual al renderer, que muestra message. Los códigos están en Errores.
Checklist de producción
- Ninguna clave
ck_en el código, en.envempaquetados ni en elapp.asar(búscala antes de publicar). - La ruta del backend exige sesión y aplica rate limit por usuario.
-
contextIsolation: true,sandbox: truey un preload que expone solo lo necesario. - Límite de 20 MB en el main y en el backend; timeouts de 60 s o más en todo el camino.
- El backend devuelve solo veredicto y motivos, no los datos extraídos, salvo que la app los necesite.
Siguientes pasos
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.
Bun
Usa Constaia con Bun sin framework: Bun.serve con rutas de subida y webhook, Bun.file para scripts y los ajustes de timeout que necesitas.