Saltar al contenido principal

SDK de JavaScript / TypeScript

npm install @veridia/sdk

El paquete es @veridia/sdk. Node 18.17 o más nuevo — el módulo de webhooks usa node:crypto.

Escrito en TypeScript con strict: true. Publica builds ESM y CJS más los archivos de declaración.

Crear un cliente

Pasá exactamente uno de publishableKey o secretKey. Pasar los dos, o ninguno, lanza una excepción en la construcción.

import { VeridiaClient } from '@veridia/sdk';

// Navegador / cliente no confiable
const client = new VeridiaClient({ publishableKey: 'qv_pub_...' });

// Tu servidor
const server = new VeridiaClient({ secretKey: process.env.VERIDIA_SECRET_KEY! });

Otras opciones: baseUrl (por defecto https://api.xxuxe.online), timeoutMs (por defecto 30000), retryOptions, circuitBreakerOptions, semaphoreOptions, logger, telemetry, fetchImpl.

Correr una verificación

// 1. Arrancala. Todos los campos son opcionales — el tenant sale de la clave.
const init = await client.verify.init({
userRef: 'your-internal-user-id', // máx. 128; se devuelve en el webhook
country: 'PY', // ISO 3166-1 alpha-2, en mayúsculas
documentType: 'dni', // solo una pista; decide el modelo de OCR
submittedFullName: 'Ada Lovelace', // máx. 255; se compara de forma difusa contra el documento
});

// init.verificationId → 'vf_...'
// init.uploads.docFront.url → hacé el PUT de los bytes acá
// init.uploads.docFront.key → devolvela en submit({ keys })
// init.expiresAt → SEGUNDOS unix; después de esto los slots dejan de funcionar

// 2. Subí las capturas.
await client.verify.uploadDocFront(init.uploads, docFrontBlob);
await client.verify.uploadDocBack(init.uploads, docBackBlob);
await client.verify.uploadSelfie(init.uploads, selfieBlob);

// 3. Enviá. `keys` es obligatorio.
await client.verify.submit({
verificationId: init.verificationId,
keys: client.verify.keysFrom(init),
});

Un pasaporte no tiene dorso: salteá uploadDocBack y llamá a client.verify.keysFrom(init, { docBack: false }). Mandar una key de docBack para bytes que nunca subiste se rechaza.

El campo es documentType, no docType

Algunos comentarios de código dentro del paquete todavía muestran docType. Esa escritura está mal, y falla en silencio: la API valida con Zod, que descarta las claves desconocidas antes de validar. Un campo no reconocido no produce ningún error — simplemente se descarta, y la pista de OCR que creías haber mandado nunca llega.

Lo mismo aplica a cualquier campo que inventes. tenantId, callbackUrl y metadata no existen en init; mandarlos devuelve 200 y no cambia nada.

Por qué keys es obligatorio

submit necesita saber cuáles de los bytes guardados son el frente del documento y cuáles la selfie. La key de cada slot de subida es lo único que lo dice. Omitilas y obtenés un 400 cuyo mensaje no va a nombrar el campo faltante — Zod descarta primero las claves desconocidas, así que un body lleno de URLs llega al validador con pinta de vacío.

keysFrom existe para que el caso común no se pueda hacer mal.

Subidas

uploadFile (y los tres wrappers que lo envuelven) reenvían presigned.headers tal cual y usan presigned.method. Eso es lo que hace que la subida funcione: esos headers llevan X-Veridia-Upload-Token, una credencial de vida corta por verificación, y el endpoint de subida se autentica con ella y no con tu API key.

Si escribís el PUT vos mismo, reenviá el objeto headers entero. No lo reconstruyas, no mandes solo Content-Type, y no le pongas un header Authorization — ahí no hace nada.

Las subidas evitan deliberadamente el cliente HTTP resiliente del SDK. Llaman a fetch directo, así que las capas de reintento, circuit breaker e idempotencia descritas más abajo no aplican, y una respuesta que no sea 2xx lanza un Error común, no un VeridiaError.

Leer el resultado

submit retorna apenas el trabajo queda encolado. El pipeline tarda unos 15 segundos.

const result = await server.verify.getStatus(verificationId);
// result.status → 'queued' | 'processing' | 'completed' | 'failed'
// result.verdict → 'approved' | 'review' | 'rejected' (ausente hasta que esté completed)

Esto requiere una clave secreta. Una clave publicable devuelve 401 secret_key_required.

poll(verificationId, options?) itera hasta que el status sea terminal, con backoff de 1.5× desde intervalMs (por defecto 1000) hasta maxIntervalMs (por defecto 5000), y se rinde después de timeoutMs (por defecto 120000) lanzando un Error. Acepta un AbortSignal.

const final = await server.verify.poll(verificationId, { timeoutMs: 60_000 });

switch (final.verdict) {
case 'approved': await activate(userId); break;
case 'rejected': await decline(userId); break;
case 'review': await queueForHuman(userId); break;
default: await handleNoDecision(final); // failed, o sin veredicto
}

Ramificá sobre verdict, nunca sobre status — una verificación rechazada también es completed. Ver status no es verdict.

El polling nunca ve el resultado de un caso que revisó una persona, que puede caer horas más tarde. Los webhooks sí.

Dos campos tipados de VerifyStatusResponse no coinciden con lo que viaja por la red

VerifyStatusResponse declara breakdown?: ConfidenceBreakdown y flags?: readonly string[]. Ninguno coincide con lo que la API devuelve realmente:

  • La API devuelve scores, no breakdown — un objeto con claves en snake_case: ocr_confidence, face_match, liveness, doc_quality, mrz_valid, name_match. result.breakdown es undefined. liveness puede ser null.
  • flags es una lista de objetos { level, text }, no de strings.

Leer cualquiera de los dos a través del tipo declarado no te da nada, y un umbral escrito contra result.breakdown.faceMatch compara undefined — que es false para toda comparación, así que el chequeo nunca se dispara y pasan todos. Leelos de la respuesta con un cast explícito hasta que los tipos se corrijan:

const raw = result as unknown as {
scores?: Record<string, number | null>;
flags?: Array<{ level: string; text: string }>;
};
const faceMatch = raw.scores?.face_match;

Los nombres de los campos y su semántica están documentados en la referencia del endpoint de status.

VerifyInitResponse tampoco modela el bloque liveness que se devuelve cuando pasás activeLiveness: true. Ese bloque es solo para el navegador — lleva beacons de reto a los que un servidor no puede responder — así que leelo de la respuesta cruda y pasáselo a tu front end.

Webhooks

Importá el verificador desde el subpath /webhooks. La raíz del paquete solo reexporta los tipos de webhook, no la función:

import { verifyWebhookSignature } from '@veridia/sdk/webhooks';

import { verifyWebhookSignature } from '@veridia/sdk' no compila y es undefined en tiempo de ejecución.

La firma

verifyWebhookSignature(
payload: string,
signatureHeader: string,
secret: string,
options?: { toleranceSec?: number; now?: number },
): WebhookEvent

Cuatro argumentos posicionales — no un objeto de opciones. Devuelve el evento parseado y lanza excepción ante un fallo. Nunca devuelve un booleano, así que un if (!isValid) escrito contra esta función no rechaza nada.

Los fallos lanzan uno de WebhookSignatureError, WebhookTimestampError o WebhookPayloadError, todos exportados desde el mismo subpath.

Express

import express from 'express';
import {
verifyWebhookSignature,
WebhookSignatureError,
WebhookTimestampError,
WebhookPayloadError,
} from '@veridia/sdk/webhooks';

const app = express();

// Body crudo, no express.json() — reserializar el JSON cambia el digest.
app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = verifyWebhookSignature(
req.body.toString('utf8'), // express.raw te da un Buffer
req.header('Veridia-Signature') ?? '', // sin el prefijo X-
process.env.VERIDIA_WEBHOOK_SECRET!,
);
} catch (err) {
if (
err instanceof WebhookSignatureError ||
err instanceof WebhookTimestampError ||
err instanceof WebhookPayloadError
) {
return res.sendStatus(400);
}
throw err;
}

// La entrega es al menos una vez. Deduplicá por event.id, que es estable
// entre reintentos, y confirmá el duplicado para que dejen de reintentarlo.
if (await alreadyProcessed(event.id)) return res.sendStatus(200);

// Respondé rápido y después hacé el trabajo lento: el dispatcher corta a los 10s.
res.sendStatus(200);

switch (event.type) {
case 'verification.approved': await activate(event.verificationId); break;
case 'verification.rejected': await decline(event.verificationId); break;
case 'verification.review_required': await queueForHuman(event.verificationId); break;
}

await markProcessed(event.id);
},
);

Tres cosas que esto corrige, cada una de las cuales rompe un handler en silencio:

  • payload tiene que ser un string. express.raw() te entrega un Buffer; pasarlo lanza WebhookPayloadError en cada entrega. Llamá a .toString('utf8').
  • El header es Veridia-Signature. req.headers['x-veridia-signature'] es undefined, y entonces el verificador lanza excepción por header faltante.
  • El discriminador es event.type. No existe event.event; un switch sobre él cae en default para siempre, devolviendo 200 sin procesar nada.

Ventana de replay

toleranceSec es 300 por defecto y ese es el valor correcto. El dispatcher vuelve a firmar en cada intento de reintento, así que el sexto reintento llega con un t fresco — no necesitás ampliar la ventana para sobrevivir al calendario de reintentos, y ampliarla solo alarga el período en que una entrega capturada puede reproducirse en tu contra.

La verificación de firma hace un chequeo de longitud antes de timingSafeEqual, así que un v1= truncado devuelve un WebhookSignatureError limpio en vez de lanzar un RangeError fuera de tu handler.

Manejo de errores

Todo error de la API es una instancia de VeridiaError o de alguna de sus subclases. Acotá con instanceof:

import {
VeridiaError,
VeridiaAuthError,
VeridiaValidationError,
VeridiaCreditError,
VeridiaResourceError,
VeridiaRateLimitError,
VeridiaServerError,
VeridiaNetworkError,
isVeridiaError,
} from '@veridia/sdk';

try {
await client.verify.init({ country: 'PY' });
} catch (err) {
if (err instanceof VeridiaRateLimitError) {
// 429 — err.retryAfter viene en SEGUNDOS, del header Retry-After
} else if (err instanceof VeridiaAuthError) {
// 401 / 403 — clave inválida, revocada, o de la familia equivocada
} else if (err instanceof VeridiaCreditError) {
// 402 — el tenant se quedó sin créditos
} else if (err instanceof VeridiaServerError || err instanceof VeridiaNetworkError) {
// transitorio; el SDK ya lo reintentó antes de que te llegue
} else if (isVeridiaError(err)) {
// cualquier otra cosa que venga de la API
}
}

isAuthError e isRateLimitError no existen. El único type guard exportado es isVeridiaError, que sirve cuando instanceof falla a través de los límites de un bundle.

La clase se elige a partir del status HTTP: 401/403 → auth, 400/422 → validación, 402 → crédito, 404/410/423 → recurso, 429 → rate limit, 5xx → servidor.

err.code no es el código de error de la API

La capa HTTP busca un campo code en el body del error, pero la API devuelve { error, message, requestId }. El code que busca nunca está ahí, así que err.code cae a 'unknown_error' para todos los errores de la API.

Ramificá sobre la clase del error o sobre err.statusCode. Un switch (err.code) contra los códigos documentados de la API — secret_key_required, insufficient_credits, rate_limited — no matchea nada.

err.message sí lleva el mensaje de la API, y err.statusCode es correcto.

Resiliencia

Reintentos con backoff exponencial y jitter, un circuit breaker y un semáforo de concurrencia envuelven cada llamada a la API. Los tres se configuran en la construcción:

const client = new VeridiaClient({
secretKey: process.env.VERIDIA_SECRET_KEY!,
retryOptions: { maxAttempts: 3 },
circuitBreakerOptions: { threshold: 5, resetMs: 30_000 },
semaphoreOptions: { maxConcurrent: 5 },
telemetry: {
onRequest: (ctx) => metrics.increment('veridia.request', { method: ctx.method }),
onResponse: (ctx) => metrics.timing('veridia.duration', ctx.durationMs),
onError: (ctx) => Sentry.captureException(ctx.error),
},
logger: pino(),
});

client.getStats() devuelve el estado actual del breaker y la cantidad de requests en vuelo.

Todo POST, PUT y PATCH recibe un header Idempotency-Key autogenerado salvo que pases uno, así que un submit reintentado no encola el trabajo dos veces. Como se dijo arriba, nada de esto cubre las subidas de imágenes.

VeridiaCircuitOpenError se lanza cuando el breaker está abierto. Se exporta desde la raíz del paquete.

Tipos exportados

import type {
DocumentType, // 'dni' | 'passport' | 'drivers_license' | 'national_id' | 'other'
Verdict, // 'approved' | 'review' | 'rejected'
VerificationStatus, // 'queued' | 'processing' | 'completed' | 'failed'
VerifyInitParams,
VerifyInitResponse,
VerifySubmitParams,
VerifySubmitResponse,
VerifyStatusResponse,
PresignedUpload,
WebhookEvent,
WebhookEventType,
} from '@veridia/sdk';

VerificationResult y VerifyState no se exportan ni se exportaron nunca — los nombres que buscás son VerifyStatusResponse y VerificationStatus.

Qué sigue