POST /v1/verify/init
Starts a new verification. Returns a verificationId plus three upload slots (document front, document back, selfie) that the client uses to upload images.
POST https://api.xxuxe.online/v1/verify/init
Why pre-create the verification ID
Calling /init first, instead of just uploading and submitting, does two things:
- Lets the client stitch its own logs together before anything reaches the backend.
- Makes the later
/submitcall idempotent — the sameverificationIdin the submit body always means the same database row.
Authentication
Bearer token. Either family works: publishable (qv_pub_ / qv_pubt_) or secret (qv_sec_ / qv_sect_).
Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9
The tenant is derived from the key. There is no tenantId field.
Request body
Every field is optional. You can POST an empty body {} and get a working verification. country and documentType meaningfully improve OCR accuracy, so send them when you have them.
| Field | Type | Description |
|---|---|---|
userRef | string | Your own user identifier, 1-128 chars. Echoed back in webhooks only |
country | string | ISO 3166-1 alpha-2, uppercase (PY, BR, MX). Exactly 2 chars |
documentType | string | One of dni, passport, drivers_license, national_id, other |
submittedFullName | string | Full name as the user typed it, 1-255 chars. Drives the name_match score |
activeLiveness | boolean | Opt in to the active-liveness challenge. Defaults to false |
The schema is non-strict: keys it does not recognise are discarded before validation. Sending tenantId, callbackUrl or metadata here returns 200 OK and the value is simply lost.
None of those three exist on /init. The tenant comes from your API key; webhook delivery is configured once per tenant in the dashboard, not per request.
userRef — where it comes back
userRef is echoed in webhook payloads. It is not returned by GET /v1/verify/:id, and it is not in the widget's veridia:complete event.
So if you plan to reconcile results by polling rather than by webhook, store the verificationId → your-user mapping on your side when you call /init. That is the only link you will have.
activeLiveness
Setting activeLiveness: true adds a server-issued challenge: the response gains a liveness block, and the client must capture and upload a sequence of frames driven by beacons it fetches one at a time. It is the strongest anti-injection signal available, and it is off by default.
It also costs about 6x the request volume — roughly 26 requests per verification instead of 4. Read Rate limits before enabling it for mobile traffic at scale.
The challenge protocol is implemented by the widget and the SDKs. If you are building a custom client and need it, talk to us before you start.
Example request
curl
curl -X POST https://api.xxuxe.online/v1/verify/init \
-H "Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9" \
-H "Content-Type: application/json" \
-d '{
"userRef": "customer-12345",
"country": "PY",
"documentType": "dni",
"submittedFullName": "Juan Carlos Perez"
}'
JavaScript / Node.js
const response = await fetch('https://api.xxuxe.online/v1/verify/init', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VERIDIA_PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
userRef: 'customer-12345',
country: 'PY',
documentType: 'dni',
submittedFullName: 'Juan Carlos Perez',
}),
});
const init = await response.json();
console.log(init.verificationId);
Python
import os
import requests
response = requests.post(
"https://api.xxuxe.online/v1/verify/init",
headers={
"Authorization": f"Bearer {os.environ['VERIDIA_PUBLISHABLE_KEY']}",
"Content-Type": "application/json",
},
json={
"userRef": "customer-12345",
"country": "PY",
"documentType": "dni",
"submittedFullName": "Juan Carlos Perez",
},
)
response.raise_for_status()
init = response.json()
print(init["verificationId"])
Response
200 OK
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"uploads": {
"docFront": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/doc-front",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/doc-front.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
},
"docBack": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/doc-back",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/doc-back.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
},
"selfie": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/selfie",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/selfie.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
}
},
"expiresAt": 1714604000
}
All three slots are always returned. Use docBack only if your document type has a back.
Response fields
| Field | Type | Description |
|---|---|---|
verificationId | string | ^vf_[A-Za-z0-9]{16,24}$. Pass to /submit and /verify/:id |
uploads.docFront | object | Upload slot for the front of the document |
uploads.docBack | object | Upload slot for the back of the document |
uploads.selfie | object | Upload slot for the selfie |
uploads.*.url | string | Where to PUT the raw image bytes |
uploads.*.key | string | Opaque handle — pass back in /submit |
uploads.*.method | string | Always "PUT" |
uploads.*.headers | object | Send these verbatim. Contains the upload token |
expiresAt | number | Unix timestamp in seconds (integer), 15 minutes after init |
liveness | object | Present only when activeLiveness: true was sent |
expiresAt is an integer count of seconds, not an ISO 8601 string. new Date(expiresAt * 1000) in JavaScript; datetime.fromtimestamp(expires_at) in Python.
Uploading the images
This is the part most custom clients get wrong, so it gets its own section.
PUT https://api.xxuxe.online/v1/verify/upload/{verificationId}/{role}
Uploads do not go to Cloudflare R2, and the slot URLs are not presigned S3 URLs. They point at the Veridia Worker itself. The Worker validates the token, checks the bytes really are a JPEG within size bounds, and writes to storage on your behalf, stamping server-controlled metadata the client cannot forge.
That is a deliberate design choice, not an implementation detail: it is the camera-to-bytes binding. No client bytes reach storage without passing this gate, which is the prerequisite for every anti-injection defence layered on top. It also means:
- Your CSP
connect-srcneedshttps://api.xxuxe.online. Allowlisting*.r2.cloudflarestorage.comdoes nothing. - Firewall rules, certificate pinning and egress allowlists should target the API host.
Authentication for uploads
The upload endpoint does not accept your API key. Sending Authorization: Bearer ... here has no effect — the auth middleware does not run on this route.
It authenticates with X-Veridia-Upload-Token, a 192-bit random token that /init generated for this one verification and placed inside every slot's headers object. It is bound to the verification and expires with the intent.
The practical rule: forward slot.headers verbatim. Do not hand-build the header object from the Content-Type you see in the example — you will drop the token and every upload will fail with 400 invalid_body, reason: "missing_upload_token", and you will never reach /submit.
Body requirements
| Rule | Value | Failure |
|---|---|---|
| Format | Real JPEG — must start with the bytes FF D8 FF | reason: "not_a_jpeg" |
| Minimum size | 100 bytes | reason: "image_too_small" |
| Maximum size | 2 MB | reason: "image_too_large" |
PNG, HEIC, WebP and PDF are all rejected. Convert to JPEG client-side before uploading.
Working example
async function uploadImage(slot, blob) {
const response = await fetch(slot.url, {
method: slot.method, // "PUT"
headers: slot.headers, // verbatim — carries X-Veridia-Upload-Token
body: blob, // raw JPEG bytes
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Upload failed: ${response.status} ${err.detail?.reason ?? ''}`);
}
return response.json(); // { ok: true, key: "verif/..." }
}
await uploadImage(init.uploads.docFront, docFrontBlob);
await uploadImage(init.uploads.selfie, selfieBlob);
// docBack only if the document has one
Note what makes this work: headers: slot.headers. Every other detail is incidental.
Declaring the capture source
Optionally send X-Veridia-Capture-Source: camera or upload to record where the pixels came from.
One rule is enforced rather than merely logged: upload is rejected for the selfie role and for any liveness frame, with reason: "upload_source_forbidden_for_biometric". A gallery photo of a face is not a selfie, and no honest client sends that combination. Documents may legitimately come from the gallery.
The header is self-reported and therefore forgeable — it is telemetry and triage, not a security control. Do not build a defence on it, and do not omit it either: honest traffic that labels itself keeps our forensic corpus clean.
Upload errors
| HTTP | Code | detail.reason | Cause |
|---|---|---|---|
400 | invalid_body | missing_upload_token | You did not forward slot.headers |
400 | invalid_body | invalid_upload_token | Token doesn't match this verification |
400 | invalid_body | key_mismatch | The role in the URL isn't one /init issued |
400 | invalid_body | not_a_jpeg | Body isn't a JPEG |
400 | invalid_body | image_too_large | Over 2 MB. Downscale before uploading — 2 MB of JPEG is 2-4 MP, ample for OCR |
400 | invalid_body | image_too_small | Under 100 bytes (usually a truncated or empty upload) |
400 | invalid_body | upload_source_forbidden_for_biometric | upload source on a selfie or liveness frame |
404 | verification_not_found | — | The intent expired (1 hour) or never existed |
429 | rate_limited | — | Per-IP limit. See Rate limits |
Errors
| HTTP | Error code | When |
|---|---|---|
400 | invalid_body | Body failed validation — see detail.fieldErrors |
401 | missing_api_key | No Authorization header |
401 | invalid_api_key | Key revoked, malformed, or never existed |
402 | insufficient_credits | Tenant balance is zero |
403 | origin_not_allowed | Browser request from an origin not on a non-empty allowed list |
429 | rate_limited | Per-IP or per-tenant limit |
500 | internal_error | Something broke on our side — report the requestId |
The codes are missing_api_key, invalid_api_key and internal_error. Not unauthorized, invalid_key or internal. Full catalog: Errors.
Notes
- Upload slots are valid for 15 minutes (
expiresAt). The verification intent itself lives for 1 hour — after that/submitand uploads returnverification_not_found. - Storage layout is
verif/<tenantId>/<verificationId>/<role>.jpg. The key is rebuilt server-side from the validated intent, never from client input. submittedFullNameis never placed in a storage path. It is PII and stays in the intent record.
What's next
After /init, upload the images, then: