Constaia
Integraciones

Google Cloud Functions

Valida documentos con Constaia en Google Cloud Functions (Cloud Run functions): multipart con busboy, secretos en Secret Manager y webhook con rawBody.

Vas a desplegar dos funciones HTTP con @google-cloud/functions-framework (Cloud Functions de 2.ª generación, hoy Cloud Run functions):

  • verifyDni: acepta el fichero en multipart/form-data (parseado con busboy) o en JSON con base64, lo analiza con Constaia y devuelve el veredicto.
  • constaiaWebhook: verifica la firma con req.rawBody y descarta duplicados.

La clave vive en Secret Manager y llega a la función como variable de entorno.

Instalación

npm i @google-cloud/functions-framework @constaia/sdk busboy
npm i -D typescript @types/busboy @types/node

En package.json, apunta main al código compilado y añade gcp-build para que el despliegue compile TypeScript:

package.json (fragmento)
{
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "gcp-build": "tsc",
    "start": "functions-framework --target=verifyDni"
  }
}

Secretos

printf 'ck_test_...' | gcloud secrets create constaia-api-key --data-file=-
printf 'whsec_...' | gcloud secrets create constaia-webhook-secret --data-file=-

La cuenta de servicio de las funciones necesita el rol roles/secretmanager.secretAccessor. En local, usa un .env con CONSTAIA_API_KEY y CONSTAIA_WEBHOOK_SECRET (por ejemplo, con node --env-file=.env).

Cliente y utilidades compartidas

src/constaia.ts
import {
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  type Analysis,
  type WebhookEvent,
} from "@constaia/sdk";

// Lee CONSTAIA_API_KEY del entorno.
export const constaia = new Constaia();

// Lo que devuelves al navegador (y lo que pinta el widget): sin los campos extraídos.
export function publicResult(analysis: Analysis) {
  const { id, object, status, document, verdict, warnings } = analysis;
  return { id, object, status, document, verdict, warnings };
}

export type HttpError = {
  status: number;
  body: { error: { code: string; message: string } };
  retryAfter?: number;
};

const httpError = (status: number, code: string, message: string, retryAfter?: number): HttpError => ({
  status,
  body: { error: { code, message } },
  retryAfter,
});

export function toHttpError(error: unknown): HttpError {
  if (error instanceof InvalidRequestError) {
    // Fichero ilegible, formato no admitido, demasiadas páginas…: el usuario puede corregirlo.
    return httpError(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
  }
  if (error instanceof RateLimitError) {
    const wait = Math.ceil(error.retryAfter ?? 1);
    return httpError(429, "rate_limited", "Demasiadas peticiones. Inténtalo en unos segundos.", wait);
  }
  if (error instanceof InsufficientCreditsError) {
    console.error("[constaia] Sin créditos. Recarga en https://app.constaia.com", error.requestId);
    return httpError(503, "unavailable", "La validación no está disponible ahora mismo.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    console.error("[constaia] Revisa CONSTAIA_API_KEY", error.code, error.requestId);
    return httpError(500, "misconfigured", "Error de configuración del servidor.");
  }
  if (error instanceof ConstaiaError) {
    // APIError (5xx), APIConnectionError, APITimeoutError
    console.error("[constaia]", error.name, error.code, error.requestId);
    return httpError(502, "upstream_error", "No se ha podido analizar el documento. Inténtalo de nuevo.");
  }
  console.error(error);
  return httpError(500, "internal_error", "Error interno.");
}

// Deduplicación por webhook-id. En producción, una tabla con clave única.
const processed = new Set<string>();
export const alreadyProcessed = (webhookId: string) => processed.has(webhookId);
export const markProcessed = (webhookId: string) => void processed.add(webhookId);

export async function handleEvent(event: WebhookEvent) {
  switch (event.type) {
    case "analysis.completed":
      // event.data es el análisis completo (con fields). Guárdalo por event.data.id.
      console.log("analysis.completed", event.data.id, event.data.verdict?.status);
      break;
    case "analysis.review_required":
      console.log("A revisión manual", event.data.id);
      break;
    case "analysis.failed":
      console.warn("analysis.failed", event.data.id, event.data.error?.code);
      break;
    case "credits.low":
      console.warn("Quedan pocos créditos: recarga en https://app.constaia.com");
      break;
  }
}

Funciones

Cloud Functions no parsea multipart/form-data, pero guarda el cuerpo completo en req.rawBody. busboy lo recorre y extrae el fichero y los campos.

src/index.ts
import * as functions from "@google-cloud/functions-framework";
import busboy from "busboy";
import { WebhookVerificationError, type AnalyzeInput } from "@constaia/sdk";
import {
  alreadyProcessed,
  constaia,
  handleEvent,
  markProcessed,
  publicResult,
  toHttpError,
} from "./constaia.js";

type Upload = { buffer: Buffer; filename: string; truncated: boolean; fields: Record<string, string> };

function parseMultipart(req: functions.Request): Promise<Upload | null> {
  return new Promise((resolve, reject) => {
    const bb = busboy({ headers: req.headers, limits: { fileSize: 20 * 1024 * 1024, files: 1 } });
    const fields: Record<string, string> = {};
    let upload: Upload | null = null;

    bb.on("field", (name, value) => {
      fields[name] = value;
    });
    bb.on("file", (name, stream, info) => {
      if (name !== "file") return void stream.resume();
      const chunks: Buffer[] = [];
      stream.on("data", (chunk: Buffer) => chunks.push(chunk));
      stream.on("end", () => {
        upload = { buffer: Buffer.concat(chunks), filename: info.filename, truncated: Boolean(stream.truncated), fields };
      });
    });
    bb.on("close", () => resolve(upload));
    bb.on("error", reject);
    bb.end(req.rawBody);
  });
}

// Protege esta función (IAM o un token de tu sistema de login): cada llamada gasta créditos.
functions.http("verifyDni", async (req, res) => {
  if (req.method !== "POST") {
    res.status(405).end();
    return;
  }

  let input: AnalyzeInput;
  let fullName = "";

  if (req.is("multipart/form-data")) {
    const upload = await parseMultipart(req);
    if (!upload) {
      res.status(400).json({ error: { code: "missing_file", message: "Falta el fichero." } });
      return;
    }
    if (upload.truncated) {
      res.status(413).json({ error: { code: "file_too_large", message: "Máximo 20 MB." } });
      return;
    }
    input = { file: upload.buffer, filename: upload.filename };
    fullName = (upload.fields.full_name ?? "").trim();
  } else if (req.is("application/json") && typeof req.body?.base64 === "string") {
    input = { base64: req.body.base64, filename: String(req.body.filename ?? "document.jpg") };
    fullName = String(req.body.full_name ?? "").trim();
  } else {
    res.status(400).json({ error: { code: "missing_file", message: "Envía multipart o JSON con base64." } });
    return;
  }

  try {
    const analysis = await constaia.analyze(input, {
      expect: "es_dni",
      checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
      storage: "none",
      language: "es",
    });
    // Guarda analysis.id y analysis.verdict en tu base de datos.
    res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
  } catch (error) {
    const { status, body, retryAfter } = toHttpError(error);
    if (retryAfter) res.set("Retry-After", String(retryAfter));
    res.status(status).json(body);
  }
});

functions.http("constaiaWebhook", async (req, res) => {
  let event;
  try {
    // req.rawBody son los bytes exactos; req.body ya viene parseado y no sirve para la firma.
    event = await constaia.webhooks.verify(req.rawBody ?? "", req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
  } catch (error) {
    if (!(error instanceof WebhookVerificationError)) throw error;
    res.status(400).send("Invalid signature");
    return;
  }

  const webhookId = req.get("webhook-id")!;
  if (!alreadyProcessed(webhookId)) {
    await handleEvent(event);
    markProcessed(webhookId);
  }
  res.status(204).end();
});
  • El servidor fija expect y checks; el nombre del titular, mejor del usuario autenticado que del formulario.
  • La deduplicación en memoria no sobrevive entre instancias. En producción usa Firestore (doc(webhookId).create() falla si ya existe) o tu base de datos.
  • El JSON con base64 ocupa un tercio más que el fichero. Para ficheros grandes es mejor subirlos a Cloud Storage y pasar una URL firmada como fileUrl (ver Firebase Functions).

Despliegue

npm run build

gcloud functions deploy verify-dni --gen2 --runtime=nodejs22 --region=europe-west1 \
  --source=. --entry-point=verifyDni --trigger-http --timeout=120s \
  --set-secrets=CONSTAIA_API_KEY=constaia-api-key:latest

gcloud functions deploy constaia-webhook --gen2 --runtime=nodejs22 --region=europe-west1 \
  --source=. --entry-point=constaiaWebhook --trigger-http --allow-unauthenticated \
  --set-secrets=CONSTAIA_API_KEY=constaia-api-key:latest,CONSTAIA_WEBHOOK_SECRET=constaia-webhook-secret:latest
  • --timeout=120s: un análisis síncrono puede tardar hasta 30 s.
  • El webhook debe ser público (--allow-unauthenticated): Constaia se autentica con la firma, no con IAM.
  • Revisa en la documentación de Google Cloud el tamaño máximo de petición HTTP de tu función.

Qué recibe el navegador

{
  "id": "an_01J…",
  "object": "analysis",
  "status": "completed",
  "document": { "type": "es_dni", "label": "DNI (España)", "confidence": 0.97, "side": "both", "country": "ESP" },
  "verdict": {
    "expected": ["es_dni"],
    "match": true,
    "status": "valid",
    "reasons": [
      { "code": "type_match", "severity": "info", "message": "El documento es DNI (España)." },
      { "code": "not_expired", "severity": "info", "message": "Vigente hasta el 12/03/2031." },
      { "code": "holder", "severity": "info", "message": "Los datos del titular coinciden (full_name)." }
    ]
  },
  "warnings": []
}

Errores

Error del SDKCuándoQué devuelve tu ruta
InvalidRequestErrorFichero vacío, ilegible, formato no admitido, más de 20 MB, demasiadas páginas u opciones mal formadas (400/409/413/415/422)422 (o 413) con el mensaje, para que el usuario suba otro fichero
RateLimitErrorSuperas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After429 con Retry-After
InsufficientCreditsErrorNo quedan créditos (402)503 al usuario y una alerta para ti: recarga en el panel
AuthenticationError, PermissionErrorClave ausente, revocada o incorrecta (401/403)500: es un fallo de configuración tuyo, no del usuario
APIError, APIConnectionError, APITimeoutErrorError 5xx de Constaia o de red, tras agotar los reintentos502 y un mensaje de reintentar

Todas extienden ConstaiaError y exponen status, code y requestId. Registra siempre el requestId: es lo que te pedirá soporte. Detalle de cada código en Errores.

Probar en modo test

npm run build && npx functions-framework --target=verifyDni --port=8080
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080
Ficherofull_nameResultado
dni_valid.jpgMaría García Lópezvalid
dni_valid.jpgJuan Pérezinvalid: motivo holder con severidad error
dni_expired.jpg(vacío)invalid: not_expired con el mensaje "Caducado el 15/06/2020."
blurry.jpg(vacío)review: motivo low_quality y warnings: ["blurry", "low_quality"]
factura.jpg(vacío)invalid: type_mismatch, no es un DNI

En modo test la respuesta depende del nombre del fichero, que debe ser un JPEG, PNG, WEBP, HEIC o PDF real (vale cualquier imagen renombrada). No gasta créditos y livemode es false. Todos los nombres en Modo test.

Con JSON y base64:

curl -H "content-type: application/json" http://localhost:8080 \
  -d "{\"filename\":\"dni_valid.jpg\",\"base64\":\"$(base64 < dni_valid.jpg | tr -d '\n')\"}"

Checklist de producción

  • verify-dni protegida (IAM o token de usuario) y con rate limit; el webhook, público pero verificado.
  • Límite de subida de 20 MB en busboy.
  • --timeout de 60 s o más.
  • PDF de más de 30 páginas o volumen alto: async: true + webhook, o lotes.
  • Deduplicación del webhook persistente (Firestore o base de datos).
  • Clave ck_live_ solo en Secret Manager.
  • Alerta de Cloud Logging ante InsufficientCreditsError y el evento credits.low.

Siguientes pasos

En esta página