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.
documentType, not docTypeSome 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.
VerifyStatusResponse do not match the wireVerifyStatusResponse declares breakdown?: ConfidenceBreakdown and flags?: readonly string[]. Neither matches what the API actually returns:
- The API returns
scores, notbreakdown— an object with snake_case keys:ocr_confidence,face_match,liveness,doc_quality,mrz_valid,name_match.result.breakdownisundefined.livenessmay benull. flagsis 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:
payloadmust be a string.express.raw()hands you aBuffer; passing it throwsWebhookPayloadErroron every delivery. Call.toString('utf8').- The header is
Veridia-Signature.req.headers['x-veridia-signature']isundefined, and the verifier then throws on a missing header. - The discriminator is
event.type. There is noevent.event; a switch on it falls through todefaultforever, returning200while 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 codeThe 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
- Webhooks — signature verification, event types, retries
- API reference — the endpoints this SDK wraps
- Python SDK · PHP SDK · Flutter SDK