Constaia
Integraciones

AWS Lambda

Valida documentos con Constaia en AWS Lambda: subida directa a S3, URL prefirmada como fileUrl, secretos en Secrets Manager y webhook firmado.

En Lambda no conviene que el fichero pase por tu función: API Gateway y las Function URLs entregan los cuerpos binarios en base64 dentro de un evento JSON, y las invocaciones síncronas tienen un límite de tamaño de payload (6 MB según la documentación de AWS). El patrón recomendado:

  1. El navegador pide a upload-url una URL prefirmada de S3 y sube el fichero directamente a S3.
  2. El navegador llama a verify con la clave del objeto. La función genera una URL de lectura prefirmada y se la pasa a Constaia como fileUrl. El fichero nunca atraviesa Lambda.
  3. webhook recibe los eventos de Constaia, verifica la firma sobre el cuerpo crudo y deduplica en DynamoDB.

La clave de Constaia está en Secrets Manager y solo la lee la función.

Instalación

npm i @constaia/sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/client-secrets-manager @aws-sdk/client-dynamodb aws-jwt-verify
npm i -D @types/aws-lambda

Runtime nodejs20.x o superior, módulos ES. Empaqueta con tu herramienta habitual (SAM, CDK, Serverless Framework, esbuild).

Secretos y configuración

Guarda un secreto JSON en Secrets Manager:

aws secretsmanager create-secret --name constaia/prod \
  --secret-string '{"CONSTAIA_API_KEY":"ck_test_...","CONSTAIA_WEBHOOK_SECRET":"whsec_..."}'

Variables de entorno de las funciones (no son secretas):

VariableValor
CONSTAIA_SECRET_IDconstaia/prod
UPLOAD_BUCKETBucket privado de subidas, en una región de la UE
USER_POOL_ID, USER_POOL_CLIENT_IDTu user pool de Cognito (para autenticar al usuario)
WEBHOOK_TABLETabla DynamoDB con clave de partición id (string) y TTL en expires_at

Si prefieres Parameter Store (SSM), cambia getSecrets por un GetParameterCommand con WithDecryption: true; el resto no cambia.

Utilidades compartidas

src/constaia.ts
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import {
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  type Analysis,
  type WebhookEvent,
} from "@constaia/sdk";

type Secrets = { CONSTAIA_API_KEY: string; CONSTAIA_WEBHOOK_SECRET: string };

// Se cargan una vez por contenedor desde Secrets Manager, no en cada invocación.
const secretsManager = new SecretsManagerClient({});
let secrets: Promise<Secrets> | undefined;
export function getSecrets(): Promise<Secrets> {
  secrets ??= secretsManager
    .send(new GetSecretValueCommand({ SecretId: process.env.CONSTAIA_SECRET_ID }))
    .then((res) => JSON.parse(res.SecretString!) as Secrets);
  return secrets;
}

let client: Constaia | undefined;
export async function getConstaia(): Promise<Constaia> {
  client ??= new Constaia({ apiKey: (await getSecrets()).CONSTAIA_API_KEY });
  return client;
}

// 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.");
}

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;
  }
}
src/auth.ts
import { CognitoJwtVerifier } from "aws-jwt-verify";
import type { APIGatewayProxyEventV2 } from "aws-lambda";

const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.USER_POOL_ID!,
  clientId: process.env.USER_POOL_CLIENT_ID!,
  tokenUse: "id",
});

export async function authenticate(event: APIGatewayProxyEventV2) {
  const token = event.headers.authorization?.replace(/^Bearer /i, "");
  if (!token) return null;
  try {
    return await verifier.verify(token);
  } catch {
    return null;
  }
}

export function readBody(event: APIGatewayProxyEventV2): string {
  if (!event.body) return "";
  return event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body;
}

export const json = (statusCode: number, body: unknown, headers: Record<string, string> = {}) => ({
  statusCode,
  headers: { "content-type": "application/json", ...headers },
  body: JSON.stringify(body),
});

1. URL de subida

src/upload-url.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { authenticate, json, readBody } from "./auth.js";

const s3 = new S3Client({});
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "application/pdf"]);

export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const user = await authenticate(event);
  if (!user) return json(401, { error: { code: "unauthorized", message: "Inicia sesión." } });

  const { filename, contentType } = JSON.parse(readBody(event) || "{}");
  if (!ALLOWED.has(contentType)) {
    return json(415, { error: { code: "unsupported_file_type", message: "Sube una imagen o un PDF." } });
  }

  // El nombre del fichero se conserva al final de la clave: Constaia lo toma de la URL.
  const safeName = String(filename ?? "document").replace(/[^\w.-]/g, "_").slice(-100);
  const key = `uploads/${user.sub}/${crypto.randomUUID()}/${safeName}`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: process.env.UPLOAD_BUCKET, Key: key, ContentType: contentType }),
    { expiresIn: 300 },
  );
  return json(200, { key, url });
}

2. Verificación

src/verify.ts
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { authenticate, json, readBody } from "./auth.js";
import { getConstaia, publicResult, toHttpError } from "./constaia.js";

const s3 = new S3Client({});

export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const user = await authenticate(event);
  if (!user) return json(401, { error: { code: "unauthorized", message: "Inicia sesión." } });

  const { key } = JSON.parse(readBody(event) || "{}");
  if (typeof key !== "string" || !key.startsWith(`uploads/${user.sub}/`)) {
    return json(403, { error: { code: "forbidden", message: "Fichero no válido." } });
  }

  // URL de lectura de corta duración: Constaia descarga el fichero (https, máx. 20 MB, 15 s).
  const fileUrl = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: process.env.UPLOAD_BUCKET, Key: key }),
    { expiresIn: 300 },
  );
  const fullName = typeof user.name === "string" ? user.name : "";

  try {
    const constaia = await getConstaia();
    const analysis = await constaia.analyze(
      { fileUrl },
      {
        expect: "es_dni",
        checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
        storage: "none",
        language: "es",
        metadata: { user_id: user.sub },
      },
    );
    // Guarda analysis.id y analysis.verdict en tu base de datos.
    return json(analysis.status === "completed" ? 200 : 202, publicResult(analysis));
  } catch (error) {
    const { status, body, retryAfter } = toHttpError(error);
    return json(status, body, retryAfter ? { "retry-after": String(retryAfter) } : {});
  }
}
  • El nombre del titular sale del token (claim name), no del formulario, y expect/checks los decide la función.
  • Añade una regla de ciclo de vida al bucket para borrar uploads/ al cabo de un día: Constaia no necesita el fichero después del análisis.
  • Si el análisis no termina en 30 s, la respuesta es 202 con status: "queued" o "processing" y el resultado llega por webhook.

En el navegador

web/verify.ts
export async function verifyDni(file: File, idToken: string) {
  const auth = { authorization: `Bearer ${idToken}`, "content-type": "application/json" };

  const upload = await fetch(UPLOAD_URL_FUNCTION, {
    method: "POST",
    headers: auth,
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then((r) => r.json());

  await fetch(upload.url, { method: "PUT", headers: { "content-type": file.type }, body: file });

  const res = await fetch(VERIFY_FUNCTION, { method: "POST", headers: auth, body: JSON.stringify({ key: upload.key }) });
  return res.json(); // { id, status, verdict, warnings, … }
}

El bucket necesita una regla CORS que permita PUT desde tu dominio.

Alternativa: base64 en JSON

Para imágenes pequeñas puedes enviar el fichero en base64 dentro del JSON y pasarlo tal cual al SDK:

src/verify-base64.ts (fragmento)
const { filename, base64 } = JSON.parse(readBody(event));
const analysis = await constaia.analyze({ base64, filename }, { expect: "es_dni", checks: { notExpired: true } });

El base64 ocupa un tercio más que el fichero y cuenta para el límite de payload de Lambda, así que no sirve para PDF ni fotos grandes.

3. Webhook

src/webhook.ts
import {
  ConditionalCheckFailedException,
  DeleteItemCommand,
  DynamoDBClient,
  PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { readBody } from "./auth.js";
import { getSecrets, handleEvent } from "./constaia.js";

const ddb = new DynamoDBClient({});
const TableName = process.env.WEBHOOK_TABLE;

export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  // Cuerpo crudo: si llega en base64 se decodifica, nunca se reserializa.
  const rawBody = readBody(event);
  const { CONSTAIA_WEBHOOK_SECRET } = await getSecrets();

  let webhookEvent: WebhookEvent;
  try {
    webhookEvent = await verifyWebhook(rawBody, event.headers, CONSTAIA_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return { statusCode: 400, body: "Invalid signature" };
    throw error;
  }

  const id = { S: event.headers["webhook-id"]! };
  try {
    await ddb.send(
      new PutItemCommand({
        TableName,
        Item: { id, expires_at: { N: String(Math.floor(Date.now() / 1000) + 7 * 24 * 3600) } },
        ConditionExpression: "attribute_not_exists(id)",
      }),
    );
  } catch (error) {
    if (error instanceof ConditionalCheckFailedException) return { statusCode: 200, body: "" };
    throw error;
  }

  try {
    await handleEvent(webhookEvent);
  } catch (error) {
    // Libera el id para que el reintento de Constaia vuelva a procesarlo.
    await ddb.send(new DeleteItemCommand({ TableName, Key: { id } }));
    throw error;
  }
  return { statusCode: 200, body: "" };
}

verifyWebhook es la función independiente del SDK: el webhook no necesita la clave de API, solo el secreto whsec_….

Timeouts y despliegue

  • Timeout de Lambda: 60 s o más en verify (un análisis síncrono puede tardar hasta 30 s, más la descarga).
  • API Gateway corta la integración en torno a 30 s (consulta el límite exacto en la documentación de AWS). Para verify, usa una Function URL, que respeta el timeout de la función, o llama con async: true y entrega el resultado por webhook.
  • Permisos IAM: secretsmanager:GetSecretValue sobre el secreto, s3:PutObject/s3:GetObject sobre uploads/*, dynamodb:PutItem/dynamodb:DeleteItem sobre la tabla.
  • La URL del webhook debe ser https y responder 2xx en menos de 15 s. Regístrala en el panel o con constaia.webhookEndpoints.create.

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

Con una clave ck_test_ en el secreto, sube un fichero llamado dni_valid.jpg. La clave de S3 termina en ese nombre y la URL prefirmada también, que es lo que Constaia usa en modo test para elegir la respuesta.

UPLOAD=$(curl -s -X POST "$UPLOAD_URL_FUNCTION" -H "authorization: Bearer $ID_TOKEN" \
  -d '{"filename":"dni_valid.jpg","contentType":"image/jpeg"}')
curl -X PUT -H "content-type: image/jpeg" --data-binary @dni_valid.jpg "$(echo "$UPLOAD" | jq -r .url)"
curl -X POST "$VERIFY_FUNCTION" -H "authorization: Bearer $ID_TOKEN" \
  -d "{\"key\":\"$(echo "$UPLOAD" | jq -r .key)\"}"
FicheroResultado
dni_valid.jpgvalid si el claim name es María García López (o no existe); invalid por holder si es otro nombre
dni_expired.jpginvalid: not_expired con severidad error
blurry.jpgreview: motivo low_quality

Más nombres en Modo test.

Checklist de producción

  • Autenticación en upload-url y verify, y rate limit por usuario (cada verificación gasta créditos).
  • Bucket privado en la UE, CORS limitado a tu dominio y ciclo de vida sobre uploads/.
  • Timeout de 60 s o más en verify; Function URL o async: true si usas API Gateway.
  • PDF de más de 30 páginas: async: true + webhook, o lotes.
  • Clave ck_live_ solo en Secrets Manager.
  • Alarma de CloudWatch sobre los logs de InsufficientCreditsError y el evento credits.low.

Siguientes pasos

En esta página