Saltar al contenido principal

SDK de PHP

composer require veridia/veridia-php

El paquete es veridia/veridia-php. PHP 8.2 o más nuevo, con ext-curl, ext-json, ext-openssl, ext-mbstring y ext-hash. Composer trae Guzzle 7, psr/log, psr/http-message y ramsey/uuid.

El análisis estático corre en PHPStan nivel 9 con reglas estrictas. Los tipos son clases readonly y enums respaldados en todo el SDK.

Crear un cliente

use Veridia\VeridiaClient;

// $secretKey sale de tu almacén de secretos — nunca del código fuente.
$client = new VeridiaClient(apiKey: $secretKey);

El constructor también toma options (un HttpClientOptions), baseUrl (por defecto VeridiaClient::DEFAULT_BASE_URL) y guzzle (para inyectar un cliente de Guzzle preconfigurado).

Este SDK corre en un servidor, así que los ejemplos usan una clave secreta. Funciona con una clave publicable hasta llegar a getStatus(), que requiere una secreta — ver tipos de clave.

Correr una verificación

El flujo es init → PUT de cada imagen → submit → conocer el resultado.

Init te entrega tres slots de subida. Cada slot lleva una key opaca; submit recibe esas keys de vuelta, y eso es lo único que liga los bytes guardados con la verificación.

1. Init

use Veridia\Types\DocumentType;
use Veridia\Types\VerifyInitParams;

$init = $client->verify->init(new VerifyInitParams(
documentType: DocumentType::DNI, // solo una pista; el OCR decide por su cuenta
userRef: 'user_42', // TU id de usuario — se devuelve en el webhook
country: 'PY', // ISO 3166-1 alpha-2, EN MAYÚSCULAS
submittedFullName: 'Ada Lovelace', // comparación difusa contra el documento
));

echo $init->verificationId; // vf_xxxxxxxxxxxxxxxx
echo date('c', $init->expiresAt); // expiresAt son SEGUNDOS UNIX, no un string de fecha

Todos los parámetros son opcionales — $client->verify->init() es una llamada válida, porque el tenant sale de la API key, no del body. No existe tenantId, ni callbackUrl, ni metadata en init; la URL del webhook se configura una vez por tenant en el dashboard.

Seteá userRef si usás webhooks. Es el único campo que ata un evento de vuelta a un usuario de tu propio sistema.

2. Subir las imágenes

$client->verify->upload($init->uploads->docFront, '/tmp/dni-front.jpg');
$client->verify->upload($init->uploads->docBack, '/tmp/dni-back.jpg');
$client->verify->upload($init->uploads->selfie, '/tmp/selfie.jpg');

upload() transmite desde disco. Para bytes que ya tenés en memoria — un frame posteado por un navegador, por ejemplo — usá uploadBytes($slot, $bytes).

Los dos reenvían los headers del slot tal cual, que es lo que hace que la subida funcione siquiera: esos headers llevan X-Veridia-Upload-Token, una credencial de vida corta por verificación con la que se autentica el endpoint, más el Content-Type contra el que valida. Los bytes tienen que ser un JPEG real (el endpoint chequea el magic number) y de 8 MB como máximo.

Las subidas evitan deliberadamente el cliente HTTP del SDK. El endpoint de subida acepta el token de subida, no tu API key, así que adjuntar un header Authorization no aporta nada y solo amplía por dónde viaja tu clave — y la política de reintentos e idempotencia afinada para llamadas JSON chicas es la política equivocada para un PUT binario de varios megabytes. Una respuesta que no sea 2xx del host de almacenamiento lanza NetworkException; un archivo ilegible lanza InvalidArgumentException.

3. Submit

use Veridia\Types\VerifySubmitKeys;
use Veridia\Types\VerifySubmitParams;

$submitted = $client->verify->submit(new VerifySubmitParams(
verificationId: $init->verificationId,
keys: VerifySubmitKeys::fromInit($init),
));

echo $submitted->status->value; // "queued" — el trabajo quedó encolado, nada más

VerifySubmitKeys::fromInit() junta las keys del resultado de init para que no te las puedas olvidar. Omitirlas es la forma más fácil de sacarle un 400 a submit, y el error no las va a nombrar: el servidor valida con Zod, que descarta las claves desconocidas antes de validar, así que un body que hablaba de URLs llega con pinta de estar simplemente vacío.

VerifySubmitParams acepta un array metadata, pero se descarta en el edge — no llega al pipeline y no está presente en el payload del webhook. Usá userRef para correlacionar en su lugar.

Submit retorna apenas el trabajo queda encolado. El pipeline tarda unos 15 segundos después de eso, y nada en esta respuesta dice nada sobre la persona.

4. Conocer el resultado

Los webhooks son el canal recomendado. Veridia te empuja el resultado en el momento en que existe: sin requests de polling, sin timeout que ajustar, y sin un intervalo de polling que pueda ser más lento que la respuesta. Ver Webhooks más abajo.

El polling es el plan B para entornos que no pueden recibir un request entrante. Necesita una clave secreta:

use Veridia\Types\VerifyVerdict;

$final = $client->verify->waitForTerminal(
verificationId: $init->verificationId,
pollIntervalMs: 2_000,
timeoutSeconds: 120,
);

match ($final->verdict) {
VerifyVerdict::APPROVED => admitUser($final),
VerifyVerdict::REVIEW => queueForHumanReview($final),
VerifyVerdict::REJECTED => declineUser($final),
null => declineUser($final), // el pipeline falló: no se llegó a una decisión
};

waitForTerminal() lanza una RuntimeException si el timeout se cumple antes. Hay una lectura antes del primer chequeo de deadline, así que incluso un timeout de un segundo da una respuesta en vez de lanzar de inmediato. Para una sola lectura no bloqueante, usá $client->verify->getStatus($verificationId).

status no es verdict

Esto es lo único que hay que hacer bien. Una verificación tiene dos ejes independientes:

CampoPregunta que respondeValores
$status¿Corrió el pipeline?queued processing completed failed
$verdict¿Pasó la persona?approved review rejectednull hasta que esté completed

completed significa que el pipeline llegó a una conclusión. No significa que la persona pasó: una verificación aprobada, una que requiere revisión y una rechazada son todas completed.

// MAL — esto admite a todos los solicitantes rechazados.
if (VerifyState::COMPLETED === $result->status) {
admitUser($user);
}

// Correcto — hacé polling sobre status, decidí sobre verdict.
if ($result->status->isTerminal()) {
match ($result->verdict) { /* ... */ };
}

Dos trampas más en la misma zona:

  • Un veredicto null no es un rechazo, y por supuesto tampoco una aprobación. Significa que no se llegó a ninguna decisión — o el pipeline sigue corriendo, o hizo failed.
  • review es final. Significa que una persona tiene que mirarlo, no que el resultado todavía se esté asentando. Hacer polling sobre un veredicto review esperando que se resuelva espera para siempre. Escribí una rama explícita para él; un if/else de dos brazos lo mete en silencio en el lado que le toque al else.

VerifyStatusResult también lleva $confidence, $scores (un array<string, float> abierto con claves en snake_case — ocr_confidence, face_match, liveness, doc_quality, mrz_valid, name_match), $flags (una lista de objetos {level, text}, no de strings), $submittedAt y $completedAt.

Los dos enums fallan ruidosamente ante un valor que no reconocen, en vez de degradar a null. Para verdict esa es la dirección segura: null legítimamente significa "sigue corriendo", así que forzar en silencio un resultado desconocido a null te entregaría un valor que se lee como "todavía no decidido" para una verificación que, de hecho, ya estaba decidida.

Documentos de una sola cara (pasaporte)

Init siempre emite los tres slots, pero un pasaporte no tiene dorso. Subí dos imágenes y decile a fromInit() que no hay dorso:

$init = $client->verify->init(new VerifyInitParams(
documentType: DocumentType::PASSPORT,
userRef: 'user_42',
));

$client->verify->upload($init->uploads->docFront, '/tmp/passport.jpg');
$client->verify->upload($init->uploads->selfie, '/tmp/selfie.jpg');
// $init->uploads->docBack simplemente queda sin escribir.

$submitted = $client->verify->submit(new VerifySubmitParams(
verificationId: $init->verificationId,
keys: VerifySubmitKeys::fromInit($init, docBack: false),
));

Las keys que mandes tienen que describir exactamente los bytes que realmente subiste. Mandar una key de docBack para un slot al que nunca le hiciste PUT se rechaza — que es lo que docBack: false existe para evitar, ya que quien copia las keys a mano naturalmente copia las tres.

Webhooks

Veridia firma cada entrega. Verificá la firma antes de confiar en nada del body.

Los headers

Veridia-Signature: t=1753000000,v1=<64 caracteres hex en minúscula>
Veridia-Event: verification.approved

Hay un solo header de firma y el timestamp vive adentro de él. No existe X-Veridia-Signature ni un X-Veridia-Timestamp aparte; el código que lea esos está leyendo headers que nunca se mandan. En PHP el header llega como $_SERVER['HTTP_VERIDIA_SIGNATURE'].

El MAC es HMAC-SHA256 sobre los bytes "<t>." + rawBody. Pasá el body crudofile_get_contents('php://input'), nunca uno recodificado. Reserializar el JSON reordena las claves y cambia los espacios, y eso cambia el digest.

Los tres tipos de evento

Exactamente estos, y ningún otro:

  • verification.approved
  • verification.review_required
  • verification.rejected

No existe verification.created ni verification.expired — un webhook se dispara solo cuando hay un resultado que reportar. Cada evento lleva un campo verdict con la misma información, así que acá ramificar sobre cualquiera de los dos está bien (a diferencia de ramificar sobre el status del pipeline).

La entrega es al menos una vez — deduplicá por id

Veridia reintenta hasta 6 veces a lo largo de unos 12,6 minutos. Los reintentos no son solo para handlers que fallaron: el caso común es un handler que funcionó y cuyo 200 se perdió por un timeout. Así que vas a ver el mismo evento dos veces. El id (evt_<hex>) es estable entre reintentos justamente para que puedas deduplicar por él; sin ese chequeo, una aprobación aprovisiona al mismo usuario varias veces.

use Veridia\Errors\WebhookException;
use Veridia\Types\EventType;
use Veridia\Webhooks\WebhookHandler;

$handler = new WebhookHandler(secret: $webhookSigningSecret);

try {
$event = $handler->verify(
payload: file_get_contents('php://input') ?: '',
signatureHeader: $_SERVER['HTTP_VERIDIA_SIGNATURE'] ?? '',
);
} catch (WebhookException $e) {
http_response_code(400);
exit;
}

// Deduplicá ANTES de actuar, y confirmá el duplicado con un 200 para que dejen de reintentarlo.
if ($store->alreadyProcessed($event->id)) {
http_response_code(200);
exit;
}

match ($event->type) {
EventType::VERIFICATION_APPROVED => admitUser($event->userRef),
EventType::VERIFICATION_REVIEW_REQUIRED => queueForHumanReview($event->userRef),
EventType::VERIFICATION_REJECTED => declineUser($event->userRef),
};

$store->markProcessed($event->id);
http_response_code(200);

El dispatcher le da 10 segundos a tu endpoint. Respondé 2xx rápido y hacé el trabajo lento después; cualquier otra cosa se reintenta, y después queda aparcada como failed para que un operador la vuelva a encolar desde el dashboard.

El payload

Plano — $event->verdict, $event->verificationId, $event->userRef, $event->confidence, $event->scores, $event->flags, $event->fieldsExtracted. No hay un envoltorio data que desenvolver. $event->createdAt son segundos unix como entero, y $event->latencyMs es cuánto tardó el pipeline.

$event->userRef es null si nunca seteaste uno en init, en cuyo caso solo $verificationId correlaciona de vuelta con un usuario.

$event->fieldsExtracted contiene nombre, número de documento y fecha de nacimiento leídos del documento de identidad. Eso es exactamente la información que tus usuarios te confiaron, así que el endpoint tiene que ser https://, y el payload no debería escribirse tal cual en los logs de la aplicación.

Ventana de replay

Las firmas se rechazan una vez que son más viejas que toleranceSeconds, que por defecto es 300. Dejalo ahí. El dispatcher vuelve a firmar en cada intento de reintento, así que incluso el último reintento llega con un t fresco — el valor por defecto cubre cómodamente todo el calendario de reintentos. Ampliarlo no aporta nada y alarga la ventana en que una entrega capturada puede reproducirse en tu contra.

El chequeo de frescura es unilateral a propósito: solo un timestamp viejo es un riesgo de replay. Que el reloj del receptor vaya atrasado respecto del emisor es rutina en VMs sin sincronizar, y rechazar ahí le echaría la culpa a "demasiado viejo" por un problema de reloj, apuntando el debugging exactamente en la dirección equivocada.

El secreto de firma no tiene ningún prefijo obligatorio. Es lo que hayas puesto en el dashboard (mínimo 24 caracteres), o un string hexadecimal de 48 caracteres si dejás que Veridia lo genere.

Manejo de errores

use Veridia\Errors\AuthException;
use Veridia\Errors\CircuitBreakerOpenException;
use Veridia\Errors\NetworkException;
use Veridia\Errors\RateLimitException;
use Veridia\Errors\ServerException;
use Veridia\Errors\TimeoutException;
use Veridia\Errors\ValidationException;
use Veridia\Errors\VeridiaException; // clase base, extiende RuntimeException

try {
$status = $client->verify->getStatus($verificationId);
} catch (AuthException $e) {
// 401 / 403. En getStatus en particular, revisá el body de la respuesta:
// $e->details['code'] === 'secret_key_required' significa que la clave es válida
// pero publicable, y este endpoint necesita una qv_sec_.
} catch (ValidationException $e) {
// 400 / 422 — el body del request estaba mal
} catch (RateLimitException $e) {
// 429 — retryAfterMs viene del header Retry-After, cuando el servidor lo manda
usleep(($e->retryAfterMs ?? 1000) * 1000);
} catch (CircuitBreakerOpenException $e) {
// el breaker se abrió: Veridia está fallando y el SDK dejó de intentar. Hacé fallar
// este request rápido en vez de encolarlo detrás de una caída.
} catch (VeridiaException $e) {
// atrapa-todo
}

Cada excepción lleva $e->requestId (citalo en los tickets de soporte), $e->statusCode, $e->details (el body de la respuesta ya parseado) y $e->toArray() para logging estructurado.

Resiliencia

Reintentos, circuit breaker y tope de concurrencia vienen activados por defecto (HttpClientOptions::defaults()). Sobreescribí cualquiera de ellos, o pasá null para desactivar una capa:

use Veridia\Http\HttpClientOptions;
use Veridia\Resilience\CircuitBreaker;
use Veridia\Resilience\ConcurrencyLimiter;
use Veridia\Resilience\RetryPolicy;

$opts = new HttpClientOptions(
apiKey: $secretKey,
baseUrl: VeridiaClient::DEFAULT_BASE_URL,
timeoutSeconds: 60.0,
connectTimeoutSeconds: 15.0,
retryPolicy: new RetryPolicy(
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 10_000,
factor: 2.0,
jitter: true, // full-jitter de AWS — evita una estampida sincronizada de reintentos
),
circuitBreaker: new CircuitBreaker(threshold: 10, resetMs: 60_000),
limiter: new ConcurrencyLimiter(maxConcurrent: 25),
);

$client = new VeridiaClient(apiKey: $secretKey, options: $opts);

Todo POST recibe un X-Idempotency-Key fresco (idem_<32 hex>) salvo que le pases uno, así que un submit reintentado no encola el trabajo dos veces.

Estas capas cubren únicamente las llamadas a la API de Veridia. upload() y uploadBytes() quedan fuera de esta maquinaria, como se describió arriba.

Telemetría y logging

Implementá TelemetryHook y registralo en un TelemetryDispatcher que pases como telemetry: en HttpClientOptions. Eventos emitidos: request.started, request.succeeded, request.failed, retry.scheduled, circuit.state_changed. Un hook que lance excepción se atrapa y se loguea como warning, nunca se relanza — un backend de métricas roto no puede voltear una verificación.

El logging es PSR-3: pasá cualquier LoggerInterface como logger:. Funciona con Monolog, Symfony Logger, Laravel Log.

Referencia de llamadas

Llamada del SDKHTTPClave requerida
verify->init(?VerifyInitParams)POST /v1/verify/initpublicable o secreta
verify->upload(PresignedUpload, string $path)PUT <url del slot>ninguna (el token va en el slot)
verify->uploadBytes(PresignedUpload, string $bytes)PUT <url del slot>ninguna
verify->submit(VerifySubmitParams)POST /v1/verify/submitpublicable o secreta
verify->getStatus(string $id)GET /v1/verify/{id}solo secreta
verify->waitForTerminal(string $id, ...)GET /v1/verify/{id} (con polling)solo secreta

Qué sigue