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:
- El navegador pide a
upload-urluna URL prefirmada de S3 y sube el fichero directamente a S3. - El navegador llama a
verifycon la clave del objeto. La función genera una URL de lectura prefirmada y se la pasa a Constaia comofileUrl. El fichero nunca atraviesa Lambda. webhookrecibe 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-lambdaRuntime 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):
| Variable | Valor |
|---|---|
CONSTAIA_SECRET_ID | constaia/prod |
UPLOAD_BUCKET | Bucket privado de subidas, en una región de la UE |
USER_POOL_ID, USER_POOL_CLIENT_ID | Tu user pool de Cognito (para autenticar al usuario) |
WEBHOOK_TABLE | Tabla 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
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;
}
}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
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
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, yexpect/checkslos 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
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:
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
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 conasync: truey entrega el resultado por webhook. - Permisos IAM:
secretsmanager:GetSecretValuesobre el secreto,s3:PutObject/s3:GetObjectsobreuploads/*,dynamodb:PutItem/dynamodb:DeleteItemsobre 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 SDK | Cuándo | Qué devuelve tu ruta |
|---|---|---|
InvalidRequestError | Fichero 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 |
RateLimitError | Superas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After | 429 con Retry-After |
InsufficientCreditsError | No quedan créditos (402) | 503 al usuario y una alerta para ti: recarga en el panel |
AuthenticationError, PermissionError | Clave ausente, revocada o incorrecta (401/403) | 500: es un fallo de configuración tuyo, no del usuario |
APIError, APIConnectionError, APITimeoutError | Error 5xx de Constaia o de red, tras agotar los reintentos | 502 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)\"}"| Fichero | Resultado |
|---|---|
dni_valid.jpg | valid si el claim name es María García López (o no existe); invalid por holder si es otro nombre |
dni_expired.jpg | invalid: not_expired con severidad error |
blurry.jpg | review: motivo low_quality |
Más nombres en Modo test.
Checklist de producción
- Autenticación en
upload-urlyverify, 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 oasync: truesi 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
InsufficientCreditsErrory el eventocredits.low.
Siguientes pasos
Edge Functions
Usa Constaia en Vercel Edge Functions y Netlify Edge Functions: subida de documentos, webhook con WebCrypto y límites de runtime a revisar.
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.