Skip to main content

JavaScript / TypeScript SDK

npm install @veridia/sdk

The package is @veridia/sdk. Node 18.17 or newer — the webhook module uses node:crypto.

Written in TypeScript with strict: true. Ships ESM and CJS builds plus declaration files.

Create a client

Provide exactly one of publishableKey or secretKey. Passing both, or neither, throws at construction.

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

// Browser / untrusted client
const client = new VeridiaClient({ publishableKey: 'qv_pub_...' });

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

Other options: baseUrl (default https://api.xxuxe.online), timeoutMs (default 30000), retryOptions, circuitBreakerOptions, semaphoreOptions, logger, telemetry, fetchImpl.

Run a verification

// 1. Start it. Every field is optional — the tenant comes from the key.
const init = await client.verify.init({
userRef: 'your-internal-user-id', // max 128; echoed back in the webhook
country: 'PY', // ISO 3166-1 alpha-2, uppercase
documentType: 'dni', // hint only; the OCR model decides
submittedFullName: 'Ada Lovelace', // max 255; fuzzy-matched against the doc
});

// init.verificationId → 'vf_...'
// init.uploads.docFront.url → PUT the bytes here
// init.uploads.docFront.key → send back in submit({ keys })
// init.expiresAt → unix SECONDS; the slots stop working after this

// 2. Upload the captures.
await client.verify.uploadDocFront(init.uploads, docFrontBlob);
await client.verify.uploadDocBack(init.uploads, docBackBlob);
await client.verify.uploadSelfie(init.uploads, selfieBlob);

// 3. Submit. `keys` is mandatory.
await client.verify.submit({
verificationId: init.verificationId,
keys: client.verify.keysFrom(init),
});

For a passport there is no back side: skip uploadDocBack and call client.verify.keysFrom(init, { docBack: false }). Sending a docBack key for bytes you never uploaded is rejected.

The field is documentType, not docType

Some doc comments inside the package still show docType. That spelling is wrong, and it fails silently: the API validates with Zod, which strips unknown keys before validating. An unrecognised field produces no error — it is simply discarded, and the OCR hint you thought you sent never arrives.

The same applies to any field you invent. tenantId, callbackUrl and metadata do not exist on init; sending them returns 200 and changes nothing.

Why keys is required

submit needs to know which stored bytes are the document front and which are the selfie. The key on each upload slot is the only thing that says so. Omit them and you get a 400 whose message will not name the missing field — Zod strips the unknown keys first, so a body full of URLs arrives at the validator looking empty.

keysFrom exists so the common case cannot be got wrong.

Uploads

uploadFile (and the three wrappers around it) forward presigned.headers verbatim and use presigned.method. That is what makes the upload work: those headers carry X-Veridia-Upload-Token, a short-lived per-verification credential, and the upload endpoint authenticates with it rather than with your API key.

If you write the PUT yourself, forward the whole headers object. Do not rebuild it, do not send only Content-Type, and do not attach an Authorization header — it does nothing there.

Uploads deliberately bypass the SDK's resilient HTTP client. They call fetch directly, so the retry, circuit breaker and idempotency layers described below do not apply to them, and a non-2xx response throws a plain Error, not a VeridiaError.

Read the result

submit returns as soon as the job is queued. The pipeline takes about 15 seconds.

const result = await server.verify.getStatus(verificationId);
// result.status → 'queued' | 'processing' | 'completed' | 'failed'
// result.verdict → 'approved' | 'review' | 'rejected' (absent until completed)

This requires a secret key. A publishable key returns 401 secret_key_required.

poll(verificationId, options?) loops until the status is terminal, with 1.5× backoff from intervalMs (default 1000) up to maxIntervalMs (default 5000), giving up after timeoutMs (default 120000) with a thrown Error. It accepts an 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, or no verdict
}

Branch on verdict, never on status — a rejected verification is completed too. See status is not verdict.

Polling never sees the outcome of a case a human reviewed, which can land hours later. Webhooks do.

Two typed fields on VerifyStatusResponse do not match the wire

VerifyStatusResponse declares breakdown?: ConfidenceBreakdown and flags?: readonly string[]. Neither matches what the API actually returns:

  • The API returns scores, not breakdown — an object with snake_case keys: ocr_confidence, face_match, liveness, doc_quality, mrz_valid, name_match. result.breakdown is undefined. liveness may be null.
  • flags is a list of objects { level, text }, not strings.

Reading either through the declared type gives you nothing, and a threshold written against result.breakdown.faceMatch compares undefined — which is false for every comparison, so the check never fires and everyone passes. Read them off the response with an explicit cast until the types are corrected:

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

The field names and semantics are documented in the status endpoint reference.

VerifyInitResponse likewise does not model the liveness block returned when you pass activeLiveness: true. That block is browser-only — it carries challenge beacons a server cannot respond to — so read it off the raw response and hand it to your front end.

Webhooks

Import the verifier from the /webhooks subpath. The package root re-exports only the webhook types, not the function:

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

import { verifyWebhookSignature } from '@veridia/sdk' does not compile and is undefined at runtime.

The signature

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

Four positional arguments — not an options object. It returns the parsed event and throws on failure. It never returns a boolean, so if (!isValid) written against it rejects nothing.

Failures throw one of WebhookSignatureError, WebhookTimestampError or WebhookPayloadError, all exported from the same subpath.

Express

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

const app = express();

// Raw body, not express.json() — re-serializing the JSON changes the digest.
app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = verifyWebhookSignature(
req.body.toString('utf8'), // express.raw gives a Buffer
req.header('Veridia-Signature') ?? '', // no X- prefix
process.env.VERIDIA_WEBHOOK_SECRET!,
);
} catch (err) {
if (
err instanceof WebhookSignatureError ||
err instanceof WebhookTimestampError ||
err instanceof WebhookPayloadError
) {
return res.sendStatus(400);
}
throw err;
}

// Delivery is at-least-once. Dedupe on event.id, which is stable
// across retries, and ack the duplicate so it stops being retried.
if (await alreadyProcessed(event.id)) return res.sendStatus(200);

// Answer fast, then do the slow work: the dispatcher times out at 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);
},
);

Three things this corrects, each of which silently breaks a handler:

  • payload must be a string. express.raw() hands you a Buffer; passing it throws WebhookPayloadError on every delivery. Call .toString('utf8').
  • The header is Veridia-Signature. req.headers['x-veridia-signature'] is undefined, and the verifier then throws on a missing header.
  • The discriminator is event.type. There is no event.event; a switch on it falls through to default forever, returning 200 while processing nothing.

Replay window

toleranceSec defaults to 300 and that is the right value. The dispatcher re-signs on every retry attempt, so the sixth retry arrives with a fresh t — you do not need to widen the window to survive the retry schedule, and widening it only lengthens the period in which a captured delivery can be replayed against you.

Signature verification uses a length check before timingSafeEqual, so a truncated v1= returns a clean WebhookSignatureError rather than throwing RangeError out of your handler.

Error handling

Every API error is an instance of VeridiaError or one of its subclasses. Narrow with 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 is SECONDS, from the Retry-After header
} else if (err instanceof VeridiaAuthError) {
// 401 / 403 — bad, revoked, or wrong-family key
} else if (err instanceof VeridiaCreditError) {
// 402 — the tenant is out of credits
} else if (err instanceof VeridiaServerError || err instanceof VeridiaNetworkError) {
// transient; already retried by the SDK before reaching you
} else if (isVeridiaError(err)) {
// anything else from the API
}
}

isAuthError and isRateLimitError do not exist. The only exported type guard is isVeridiaError, which is useful when instanceof fails across bundle boundaries.

The class is chosen from the HTTP status: 401/403 → auth, 400/422 → validation, 402 → credit, 404/410/423 → resource, 429 → rate limit, 5xx → server.

err.code is not the API error code

The HTTP layer looks for a code field in the error body, but the API returns { error, message, requestId }. The code it looks for is never there, so err.code falls back to 'unknown_error' for every API error.

Branch on the error class or on err.statusCode. A switch (err.code) against the API's documented codes — secret_key_required, insufficient_credits, rate_limited — matches nothing.

err.message does carry the API's message, and err.statusCode is accurate.

Resilience

Retry with exponential backoff and jitter, a circuit breaker, and a concurrency semaphore wrap every call to the API. All three are configurable at construction:

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() returns the current breaker state and in-flight count.

Every POST, PUT and PATCH gets an auto-generated Idempotency-Key header unless you pass one, so a retried submit does not enqueue the job twice. As noted above, none of this covers image uploads.

VeridiaCircuitOpenError is thrown when the breaker is open. It is exported from the package root.

Exported types

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 and VerifyState are not exported and never were — the names you want are VerifyStatusResponse and VerificationStatus.

What's next