Saltar al contenido principal

SDK de Python

pip install veridia

El paquete es veridia. Python 3.11 o más nuevo. Construido sobre httpx (con HTTP/2 habilitado) y Pydantic v2, y chequeado con mypy --strict.

La superficie del SDK es snake_case (verification_id, fields_extracted) mientras que la API HTTP es camelCase. La conversión ocurre en el límite, así que vos escribís Python y lo que viaja por la red sigue siendo válido. Si pegás un dict en camelCase sacado directo de la referencia HTTP, eso también valida.

Crear un cliente

from veridia import VeridiaClient

client = VeridiaClient(api_key="qv_sec_...")

Todos los argumentos son keyword-only. Además de api_key: base_url (por defecto https://api.xxuxe.online), timeout_s (30.0), user_agent, retry_policy, concurrency_limiter, circuit_breaker, telemetry, logger.

El cliente es un context manager, y cerrarlo libera el pool de conexiones de httpx:

with VeridiaClient(api_key="qv_sec_...") as client:
...

Correr una verificación

from veridia import VeridiaClient, keys_from

client = VeridiaClient(api_key="qv_sec_...")

# 1. Abrí una verificación y obtené un slot de subida por imagen.
# Todos los campos son opcionales — el tenant sale de la API key.
init = client.verify.init({
"user_ref": "user_42", # máx. 128; se devuelve en el webhook
"country": "PY", # ISO 3166-1 alpha-2, en mayúsculas
"document_type": "dni", # solo una pista; decide el modelo de OCR
"submitted_full_name": "Ada Lovelace", # máx. 255; comparación difusa
})

# init.verification_id → 'vf_...'
# init.expires_at → SEGUNDOS unix (un int), no un string de fecha

# 2. PUT de los bytes. Tienen que ser JPEG real, de 8 MB como máximo cada uno.
client.verify.upload(init.uploads.doc_front, Path("front.jpg").read_bytes())
client.verify.upload(init.uploads.doc_back, Path("back.jpg").read_bytes())
client.verify.upload(init.uploads.selfie, Path("selfie.jpg").read_bytes())

# 3. Encolala. `keys` es obligatorio.
client.verify.submit(init.verification_id, keys_from(init))

Para un pasaporte o cualquier documento de una sola cara, salteá el dorso y decile a keys_from que no hay:

client.verify.upload(init.uploads.doc_front, front_bytes)
client.verify.upload(init.uploads.selfie, selfie_bytes)
client.verify.submit(init.verification_id, keys_from(init, doc_back=False))

Init siempre emite los tres slots. Mandar una key de doc_back para un slot en el que nunca escribiste se rechaza — que es exactamente lo que doc_back=False existe para evitar, ya que alguien que copia las keys a mano naturalmente copia las tres.

Firmas de los métodos

verify.init(params: VerifyInitParams | None = None) -> VerifyInitResult
verify.upload(slot: PresignedUpload, content: bytes) -> None
verify.submit(
verification_id: str,
keys: VerifySubmitKeys,
*,
liveness_score: float | None = None,
) -> VerifySubmitResult
verify.status(verification_id: str) -> VerifyStatusResult

client.verify.init() sin argumentos es una llamada válida.

init acepta claves desconocidas sin quejarse, y el servidor también: la API valida con Zod, que descarta las claves desconocidas antes de validar. Un campo mal escrito no produce ningún error — se descarta en silencio. No existe tenant_id, ni callback_url, ni metadata en init.

Por qué keys es obligatorio

submit necesita saber cuáles de los bytes guardados son el documento y cuáles la selfie. La key de cada slot es lo único que lo dice, y olvidarlas es la forma más fácil de sacarle un 400 a submit — uno cuyo mensaje no va a nombrar qué falta, porque Zod descartó primero los campos desconocidos y el body llegó con pinta de vacío.

keys_from(init) las junta para que el caso común no se pueda hacer mal.

Subidas

upload() manda slot.headers tal cual. Esos headers no son decoración: llevan X-Veridia-Upload-Token, una credencial de vida corta por verificación, y sin él el endpoint responde 400 missing_upload_token. Tu API key no autentica ese endpoint en absoluto.

upload() llama a httpx directo en vez de pasar por el cliente del SDK. Es deliberado — adjuntar tu API key no aporta nada y solo amplía por dónde viaja, 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 levanta RuntimeError, no un VeridiaError.

Leer el resultado

submit retorna apenas el trabajo queda encolado; el pipeline tarda unos 15 segundos. Preferí el webhook. Hacé polling solo si no podés recibir un request entrante.

status = client.verify.status(init.verification_id) # requiere clave secreta

status.status # "queued" | "processing" | "completed" | "failed"
status.verdict # "approved" | "review" | "rejected" — None hasta que esté completed

Una clave publicable acá devuelve 401 secret_key_required, que aflora como AuthError.

Ramificá sobre verdict, nunca sobre status:

if status.status in ("completed", "failed"):
match status.verdict:
case "approved": activate(user_id)
case "rejected": decline(user_id)
case "review": queue_for_human(user_id)
case None: handle_no_decision(status)

completed significa que el pipeline corrió, no que la persona pasó — una verificación rechazada también es completed. Un veredicto None no es un rechazo; significa que no se llegó a ninguna decisión. Y review es final: una persona tiene que mirarlo. Hacer polling sobre un review esperando que se resuelva espera para siempre.

Campos de VerifyStatusResult: verification_id, status, verdict, confidence, scores, flags, submitted_at, completed_at.

  • scores es un dict con claves en snake_case: ocr_confidence, face_match, liveness, doc_quality, mrz_valid, name_match. Leelo con .get() — el conjunto es abierto y liveness en particular puede faltar cuando el pipeline no tuvo señal de prueba de vida.
  • flags es una lista de dicts {"level": ..., "text": ...}, no una lista de strings.

VerifySubmitResult lleva status_url, la URL absoluta del endpoint de status. Leerla sigue necesitando una clave secreta.

VerifyInitResult.liveness es un dict crudo, presente solo cuando pasaste active_liveness: True. Se dejó sin modelar a propósito: contiene beacons de reto a los que un navegador tiene que reaccionar en tiempo real, cosa que un proceso de servidor no puede hacer. Pasalo intacto al front end que lo vaya a correr.

Async

La misma API con await adelante. Cada método tiene su gemelo asíncrono.

import asyncio
from veridia import AsyncVeridiaClient, keys_from

async def main() -> None:
async with AsyncVeridiaClient(api_key="qv_sec_...") as client:
init = await client.verify.init({"country": "PY", "user_ref": "user_42"})
await client.verify.upload(init.uploads.doc_front, front_bytes)
await client.verify.upload(init.uploads.selfie, selfie_bytes)
await client.verify.submit(
init.verification_id, keys_from(init, doc_back=False)
)

asyncio.run(main())

Webhooks

from veridia import VeridiaClient, WebhookError

event = VeridiaClient.verify_webhook(
payload, # bytes CRUDOS — no json.loads(), no recodificados
signature_header, # valor del header Veridia-Signature
secret,
tolerance_s=300, # por defecto; dejalo así
)

verify_webhook es un staticmethod — no hace falta instanciar un cliente. veridia.verify_signature es la misma función bajo su nombre a nivel de módulo, y veridia.construct_event es un alias de ella para quienes vienen del SDK de Stripe. Las tres levantan WebhookError ante un header malformado, una firma que no coincide, un timestamp vencido o un body imposible de parsear.

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. No salgas a buscar un prefijo whsec_ — esa es una convención de Stripe que aparece en algunos valores de ejemplo acá, pero no es parte del formato.

FastAPI

from fastapi import FastAPI, Request, Response
from veridia import VeridiaClient, WebhookError

app = FastAPI()

@app.post("/webhooks/veridia")
async def veridia_webhook(request: Request) -> Response:
try:
event = VeridiaClient.verify_webhook(
await request.body(), # bytes crudos
request.headers.get("Veridia-Signature", ""),
settings.VERIDIA_WEBHOOK_SECRET,
)
except WebhookError:
return Response(status_code=400)

# La entrega es al menos una vez. Deduplicá por event.id — es estable entre
# reintentos — y confirmá el duplicado para que dejen de reintentarlo.
if already_processed(event.id):
return Response(status_code=200)

if event.type == "verification.approved":
activate_account(event.user_ref)
elif event.type == "verification.review_required":
queue_for_manual_review(event.user_ref)
elif event.type == "verification.rejected":
decline(event.user_ref)

mark_processed(event.id)
return Response(status_code=200)

Usá await request.body(). request.json() te da un objeto ya parseado, y reserializarlo para verificar el MAC reordena las claves y cambia los espacios — el digest no va a coincidir.

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 6 veces a lo largo de unos 12,6 minutos, y después queda aparcada como failed para que un operador la vuelva a encolar desde el dashboard.

Campos del evento

El payload es planoevent.verdict, no event.data["verdict"].

CampoTipoNotas
idstrevt_<hex>. Estable entre reintentos — deduplicá por esto
typestrUno de exactamente tres valores (abajo)
created_atintSegundos unix, no un string ISO
tenant_idstr
verification_idstr
verdictstrapproved / review / rejected
confidencefloat | None
user_refstr | NoneLo que pasaste en init; None si nunca seteaste uno
scoresdict[str, float]claves en snake_case, como arriba
flagslist[dict]
fields_extracteddictPII de identidad — ver abajo
latency_msint | None

Exactamente tres tipos de evento: verification.approved, verification.review_required, verification.rejected. No hay evento created ni expired — un integrador que espere uno espera para siempre.

event.fields_extracted contiene nombre completo, número de documento y fecha de nacimiento. Por eso la URL del webhook tiene que ser https, y por eso el payload no debería escribirse tal cual en los logs de la aplicación.

Si nunca seteaste user_ref en init, solo verification_id correlaciona el evento de vuelta con un usuario — y el camino del polling no devuelve user_ref para nada, así que guardá vos mismo el mapeo.

Ventana de replay

tolerance_s es 300 por defecto y eso es correcto. El dispatcher vuelve a firmar en cada intento de reintento, así que incluso el último reintento llega con un t fresco. Ampliar la ventana no aporta nada y alarga el período en que una entrega capturada puede reproducirse en tu contra.

Manejo de errores

Toda excepción hereda de VeridiaError.

from veridia import (
VeridiaError,
AuthError, # 401, 403
ValidationError, # 400, 422
RateLimitError, # 429 — tiene .retry_after_ms
ServerError, # 5xx (se reintenta solo)
NetworkError, # problemas de conexión (se reintenta solo)
TimeoutError, # timeout del request (se reintenta solo)
CircuitBreakerOpenError, # el breaker está OPEN
WebhookError, # problemas de firma / payload
)

try:
init = client.verify.init({"country": "PY"})
except RateLimitError as e:
time.sleep((e.retry_after_ms or 1000) / 1000)
except AuthError:
... # clave inválida, revocada, o de la familia equivocada
except VeridiaError as e:
log.error("veridia failed: %s (request_id=%s)", e.message, e.request_id)

Citá e.request_id en los tickets de soporte.

veridia.TimeoutError tapa al builtin del mismo nombre si lo importás pelado. Importá el módulo o poné un alias si eso importa en tu código.

Resiliencia

Cuatro capas envuelven cada llamada a la API, todas sobreescribibles:

from veridia import (
VeridiaClient, RetryPolicy, CircuitBreaker, ConcurrencyLimiter, Telemetry, StdLogger,
)

client = VeridiaClient(
api_key="qv_sec_...",
retry_policy=RetryPolicy(
max_attempts=5, base_delay_ms=500, max_delay_ms=10_000, factor=2.5, jitter=True,
),
circuit_breaker=CircuitBreaker(threshold=10, reset_ms=60_000),
concurrency_limiter=ConcurrencyLimiter(max_concurrent=20),
telemetry=Telemetry(hooks={"on_request": on_request, "on_error": on_error}),
logger=StdLogger(),
)

Valores por defecto: ConcurrencyLimiter(10), CircuitBreaker(5, 30_000), telemetría y logger no-op. jitter=True es el patrón full-jitter de AWS, que evita una estampida sincronizada de reintentos entre tus workers.

Todo POST/PUT/PATCH recibe un Idempotency-Key generado, así que un submit reintentado no encola el trabajo dos veces. Como se dijo arriba, nada de esto cubre upload().

Qué sigue