Fastify
Integra Constaia en Fastify 5 con @fastify/multipart para validar documentos y un webhook con parser de cuerpo crudo encapsulado.
Vas a montar un servidor Fastify 5 con:
POST /api/verify-dni: recibe el fichero con@fastify/multipart, lo analiza con Constaia y devuelve el veredicto.POST /webhooks/constaia: verifica la firma con el cuerpo crudo gracias a un parser de contenido propio, limitado a esa ruta.
La clave de API vive solo en el servidor. El navegador o el widget envían el fichero a tu ruta.
Instalación
npm i fastify @fastify/multipart @constaia/sdk
npm i -D typescript tsxRequisitos: Node.js 20 o superior, Fastify 5 y @fastify/multipart 9.
Variables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...La clave se crea en el panel → API keys; el secreto del webhook aparece una sola vez al crear el endpoint (Webhooks).
Cliente y utilidades compartidas
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;
}
}Servidor
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { WebhookVerificationError } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
const app = Fastify({ logger: true });
await app.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024, files: 1, fields: 5 },
});
// Aquí va tu autenticación (por ejemplo un hook onRequest): cada llamada gasta créditos.
app.post("/api/verify-dni", async (request, reply) => {
let file: { buffer: Buffer; filename: string } | undefined;
let fullName = "";
for await (const part of request.parts()) {
if (part.type === "file" && part.fieldname === "file") {
// toBuffer() lanza un error 413 si supera limits.fileSize.
file = { buffer: await part.toBuffer(), filename: part.filename };
} else if (part.type === "file") {
part.file.resume();
} else if (part.fieldname === "full_name") {
fullName = String(part.value).trim();
}
}
if (!file) {
return reply.code(400).send({ error: { code: "missing_file", message: "Falta el fichero." } });
}
try {
const analysis = await constaia.analyze(file.buffer, {
filename: file.filename,
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "es",
});
// Guarda analysis.id y analysis.verdict en tu base de datos.
return reply.code(analysis.status === "completed" ? 200 : 202).send(publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
if (retryAfter) reply.header("Retry-After", retryAfter);
return reply.code(status).send(body);
}
});
// Contexto encapsulado: el parser de cuerpo crudo solo afecta a esta ruta.
await app.register(async (scope) => {
scope.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
done(null, body);
});
scope.post("/webhooks/constaia", async (request, reply) => {
let event;
try {
event = await constaia.webhooks.verify(
request.body as string,
request.headers,
process.env.CONSTAIA_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof WebhookVerificationError) return reply.code(400).send("Invalid signature");
throw error;
}
const webhookId = request.headers["webhook-id"] as string;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
return reply.code(200).send();
});
});
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });Arranca con:
npx tsx --env-file=.env src/server.tsPuntos clave:
request.parts()recorre ficheros y campos en el orden en que llegan, así que da igual sifull_nameva antes o después del fichero. Los ficheros que no esperas se descartan conpart.file.resume().- El servidor decide
expectychecks, nunca el cliente. El nombre del titular, mejor de la sesión que del formulario. filenamees obligatorio en la práctica cuando pasas unBuffer: en modo test el nombre decide la respuesta.- Parser encapsulado.
addContentTypeParserdentro deapp.registerno cambia el parser JSON del resto de la aplicación. - 202 cuando el análisis sigue en
queuedoprocessingtras 30 s: el resultado llega por webhook.
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": []
}Es el formato que pinta el widget: <constaia-upload endpoint="/api/verify-dni">.
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.
Si el fichero supera limits.fileSize, @fastify/multipart lanza RequestFileTooLargeError y Fastify responde 413 sin llegar a Constaia.
Webhook
- La firma se calcula sobre el cuerpo exacto. Con
parseAs: "string"recibes el texto tal cual; no usesJSON.stringify(request.body). webhook-ides estable entre reintentos: guárdalo con una restricción única.- Responde 2xx en menos de 15 s; encola el trabajo pesado.
- Si proteges rutas con un hook global de autenticación, excluye
/webhooks/constaia.
Probar en modo test
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:3000/api/verify-dni| Fichero | full_name | Resultado |
|---|---|---|
dni_valid.jpg | María García López | valid |
dni_valid.jpg | Juan Pérez | invalid: 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.
Para el webhook, Fastify tiene inject, que no abre puertos:
import { signWebhook } from "@constaia/sdk";
const payload = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed", verdict: { status: "valid", reasons: [] } },
});
const headers = await signWebhook(payload, process.env.CONSTAIA_WEBHOOK_SECRET!);
const res = await app.inject({
method: "POST",
url: "/webhooks/constaia",
headers: { ...headers, "content-type": "application/json" },
payload,
});
console.log(res.statusCode); // 200(Para usar inject, exporta app desde un módulo sin listen.)
Checklist de producción
- Autenticación y rate limit (por ejemplo
@fastify/rate-limit) en/api/verify-dni. - Límite de subida de 20 MB en
@fastify/multiparty algo más (21 MB) en el proxy. - Timeouts de 60 s o más en proxy y balanceador; un análisis síncrono puede tardar hasta 30 s.
- PDF de más de 30 páginas o volumen alto:
async: true+ webhook, o lotes. - Clave
ck_live_solo en el entorno del servidor. - Alerta ante
InsufficientCreditsErrory el eventocredits.low.