Skip to main content

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:

  1. Lets the client stitch its own logs together before anything reaches the backend.
  2. Makes the later /submit call idempotent — the same verificationId in 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.

FieldTypeDescription
userRefstringYour own user identifier, 1-128 chars. Echoed back in webhooks only
countrystringISO 3166-1 alpha-2, uppercase (PY, BR, MX). Exactly 2 chars
documentTypestringOne of dni, passport, drivers_license, national_id, other
submittedFullNamestringFull name as the user typed it, 1-255 chars. Drives the name_match score
activeLivenessbooleanOpt in to the active-liveness challenge. Defaults to false
Unknown fields vanish without an error

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

FieldTypeDescription
verificationIdstring^vf_[A-Za-z0-9]{16,24}$. Pass to /submit and /verify/:id
uploads.docFrontobjectUpload slot for the front of the document
uploads.docBackobjectUpload slot for the back of the document
uploads.selfieobjectUpload slot for the selfie
uploads.*.urlstringWhere to PUT the raw image bytes
uploads.*.keystringOpaque handle — pass back in /submit
uploads.*.methodstringAlways "PUT"
uploads.*.headersobjectSend these verbatim. Contains the upload token
expiresAtnumberUnix timestamp in seconds (integer), 15 minutes after init
livenessobjectPresent 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-src needs https://api.xxuxe.online. Allowlisting *.r2.cloudflarestorage.com does 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

RuleValueFailure
FormatReal JPEG — must start with the bytes FF D8 FFreason: "not_a_jpeg"
Minimum size100 bytesreason: "image_too_small"
Maximum size2 MBreason: "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

HTTPCodedetail.reasonCause
400invalid_bodymissing_upload_tokenYou did not forward slot.headers
400invalid_bodyinvalid_upload_tokenToken doesn't match this verification
400invalid_bodykey_mismatchThe role in the URL isn't one /init issued
400invalid_bodynot_a_jpegBody isn't a JPEG
400invalid_bodyimage_too_largeOver 2 MB. Downscale before uploading — 2 MB of JPEG is 2-4 MP, ample for OCR
400invalid_bodyimage_too_smallUnder 100 bytes (usually a truncated or empty upload)
400invalid_bodyupload_source_forbidden_for_biometricupload source on a selfie or liveness frame
404verification_not_foundThe intent expired (1 hour) or never existed
429rate_limitedPer-IP limit. See Rate limits

Errors

HTTPError codeWhen
400invalid_bodyBody failed validation — see detail.fieldErrors
401missing_api_keyNo Authorization header
401invalid_api_keyKey revoked, malformed, or never existed
402insufficient_creditsTenant balance is zero
403origin_not_allowedBrowser request from an origin not on a non-empty allowed list
429rate_limitedPer-IP or per-tenant limit
500internal_errorSomething 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 /submit and uploads return verification_not_found.
  • Storage layout is verif/<tenantId>/<verificationId>/<role>.jpg. The key is rebuilt server-side from the validated intent, never from client input.
  • submittedFullName is never placed in a storage path. It is PII and stays in the intent record.

What's next

After /init, upload the images, then:

POST /v1/verify/submit →