Python SDK
pip install veridia
The package is veridia. Python 3.11 or newer. Built on httpx (HTTP/2 enabled) and Pydantic v2, and type-checked under mypy --strict.
The SDK surface is snake_case (verification_id, fields_extracted) while the HTTP API is camelCase. The conversion happens at the boundary, so you write Python and the wire stays valid. If you paste a camelCase dict straight out of the HTTP reference, that validates too.
Create a client
from veridia import VeridiaClient
client = VeridiaClient(api_key="qv_sec_...")
All arguments are keyword-only. Besides api_key: base_url (default https://api.xxuxe.online), timeout_s (30.0), user_agent, retry_policy, concurrency_limiter, circuit_breaker, telemetry, logger.
The client is a context manager, and closing it releases the underlying httpx connection pool:
with VeridiaClient(api_key="qv_sec_...") as client:
...
Run a verification
from veridia import VeridiaClient, keys_from
client = VeridiaClient(api_key="qv_sec_...")
# 1. Open a verification and get one upload slot per image.
# Every field is optional — the tenant comes from the API key.
init = client.verify.init({
"user_ref": "user_42", # max 128; echoed back in the webhook
"country": "PY", # ISO 3166-1 alpha-2, uppercase
"document_type": "dni", # hint only; the OCR model decides
"submitted_full_name": "Ada Lovelace", # max 255; fuzzy-matched
})
# init.verification_id → 'vf_...'
# init.expires_at → unix SECONDS (an int), not a date string
# 2. PUT the bytes. Must be real JPEG, at most 8 MB each.
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. Queue it. `keys` is required.
client.verify.submit(init.verification_id, keys_from(init))
For a passport or any single-sided document, skip the back and tell keys_from there is none:
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 always issues all three slots. Sending a doc_back key for a slot you never wrote to is rejected — which is exactly what doc_back=False exists to prevent, since someone copying the keys across by hand naturally copies all three.
Method signatures
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() with no arguments is a valid call.
init accepts unknown keys without complaint, and so does the server: the API validates with Zod, which strips unknown keys before validating. A misspelled field produces no error — it is silently discarded. There is no tenant_id, no callback_url and no metadata on init.
Why keys is required
submit needs to know which stored bytes are the document and which are the selfie. The key on each slot is the only thing that says so, and forgetting them is the easiest way to get a 400 out of submit — one whose message will not name what is missing, because Zod stripped the unknown fields first and the body arrived looking empty.
keys_from(init) collects them so the common case cannot be got wrong.
Uploads
upload() sends slot.headers verbatim. Those headers are not decoration: they carry X-Veridia-Upload-Token, a short-lived per-verification credential, and the endpoint answers 400 missing_upload_token without it. Your API key does not authenticate that endpoint at all.
upload() calls httpx directly rather than going through the SDK's client. That is deliberate — attaching your API key buys nothing and only widens where it travels, and the retry and idempotency policy tuned for small JSON calls is the wrong policy for a multi-megabyte binary PUT. A non-2xx response raises RuntimeError, not a VeridiaError.
Read the result
submit returns as soon as the job is queued; the pipeline takes about 15 seconds. Prefer the webhook. Poll only if you cannot receive an inbound request.
status = client.verify.status(init.verification_id) # secret key required
status.status # "queued" | "processing" | "completed" | "failed"
status.verdict # "approved" | "review" | "rejected" — None until completed
A publishable key here returns 401 secret_key_required, surfaced as AuthError.
Branch on verdict, never on 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 means the pipeline ran, not that the person passed — a rejected verification is completed too. A None verdict is not a rejection; it means no decision was reached. And review is final: a human has to look at it. Polling a review waiting for it to resolve waits forever.
VerifyStatusResult fields: verification_id, status, verdict, confidence, scores, flags, submitted_at, completed_at.
scoresis a dict with snake_case keys:ocr_confidence,face_match,liveness,doc_quality,mrz_valid,name_match. Read with.get()— the set is open-ended andlivenessin particular can be absent when the pipeline had no liveness signal.flagsis a list of dicts{"level": ..., "text": ...}, not a list of strings.
VerifySubmitResult carries status_url, the absolute URL of the status endpoint. Reading it still needs a secret key.
VerifyInitResult.liveness is a raw dict, present only when you passed active_liveness: True. It is left unmodelled on purpose: it contains challenge beacons a browser has to react to in real time, which a server process cannot do. Hand it through intact to whatever front end will run it.
Async
Identical API with await in front. Every method has an async twin.
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, # RAW bytes — not json.loads(), not re-encoded
signature_header, # value of the Veridia-Signature header
secret,
tolerance_s=300, # default; leave it alone
)
verify_webhook is a staticmethod — no client instance needed. veridia.verify_signature is the same function under its module-level name, and veridia.construct_event is an alias of it for people used to the Stripe SDK. All three raise WebhookError on a malformed header, a signature mismatch, an expired timestamp or an unparseable body.
The signing secret has no required prefix. It is whatever you set in the dashboard (minimum 24 characters), or a 48-character hex string if you let Veridia generate one. Do not go looking for a whsec_ prefix — that is a Stripe convention that appears in some sample values here but is not part of the format.
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(), # raw bytes
request.headers.get("Veridia-Signature", ""),
settings.VERIDIA_WEBHOOK_SECRET,
)
except WebhookError:
return Response(status_code=400)
# Delivery is at-least-once. Dedupe on event.id — it is stable across
# retries — and ack the duplicate so it stops being retried.
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)
Use await request.body(). request.json() gives you a parsed object, and re-serializing it to verify the MAC reorders keys and changes spacing — the digest will not match.
The dispatcher gives your endpoint 10 seconds. Answer 2xx quickly and do slow work afterwards; anything else is retried 6 times over roughly 12.6 minutes, then parked as failed for an operator to re-queue from the dashboard.
Event fields
The payload is flat — event.verdict, not event.data["verdict"].
| Field | Type | Notes |
|---|---|---|
id | str | evt_<hex>. Stable across retries — deduplicate on this |
type | str | One of exactly three values (below) |
created_at | int | Unix seconds, not an ISO string |
tenant_id | str | |
verification_id | str | |
verdict | str | approved / review / rejected |
confidence | float | None | |
user_ref | str | None | What you passed at init; None if you never set one |
scores | dict[str, float] | snake_case keys, as above |
flags | list[dict] | |
fields_extracted | dict | Identity PII — see below |
latency_ms | int | None |
Exactly three event types: verification.approved, verification.review_required, verification.rejected. There is no created and no expired event — an integrator waiting on one waits forever.
event.fields_extracted contains full name, document number and date of birth. That is why the webhook URL must be https, and why the payload should not be written verbatim into application logs.
If you never set user_ref at init, only verification_id correlates the event back to a user — and the polling path does not return user_ref at all, so store the mapping yourself.
Replay window
tolerance_s defaults to 300 and that is correct. The dispatcher re-signs on every retry attempt, so even the last retry arrives with a fresh t. Widening the window buys nothing and lengthens the period in which a captured delivery can be replayed against you.
Error handling
Every exception inherits from VeridiaError.
from veridia import (
VeridiaError,
AuthError, # 401, 403
ValidationError, # 400, 422
RateLimitError, # 429 — has .retry_after_ms
ServerError, # 5xx (auto-retried)
NetworkError, # connection issues (auto-retried)
TimeoutError, # request timeout (auto-retried)
CircuitBreakerOpenError, # the breaker is OPEN
WebhookError, # signature / payload problems
)
try:
init = client.verify.init({"country": "PY"})
except RateLimitError as e:
time.sleep((e.retry_after_ms or 1000) / 1000)
except AuthError:
... # bad, revoked, or wrong-family key
except VeridiaError as e:
log.error("veridia failed: %s (request_id=%s)", e.message, e.request_id)
Quote e.request_id in support tickets.
veridia.TimeoutError shadows the builtin of the same name if you import it bare. Import the module or alias it if that matters in your codebase.
Resilience
Four layers wrap every API call, all overridable:
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(),
)
Defaults: ConcurrencyLimiter(10), CircuitBreaker(5, 30_000), no-op telemetry and logger. jitter=True is the AWS full-jitter pattern, which avoids a synchronized retry stampede across your workers.
Every POST/PUT/PATCH gets a generated Idempotency-Key, so a retried submit does not enqueue the job twice. As noted above, none of this covers upload().
What's next
- Webhooks — signature verification, event types, retries
- API reference — the endpoints this SDK wraps
- JavaScript SDK · PHP SDK · Flutter SDK