Skip to main content

POST /v1/verify/submit

After your client has uploaded the document and selfie images to R2 (using the presigned URLs from /init), call /submit to trigger the verification pipeline.

POST https://api.xxuxe.online/v1/verify/submit

This is the call that:

  1. Verifies all uploaded images actually landed in R2
  2. Runs OCR on the document via Workers AI
  3. Debits one credit from the tenant's balance
  4. Dispatches the job to the ML backend for face match + liveness + verdict
  5. Returns 202 Accepted with a status URL to poll

Authentication

Bearer token. Same key used in /init (the tenant must match).

Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9

Request body

FieldTypeRequiredDescription
verificationIdstringYesThe ID returned by /init. Format: vf_[A-Za-z0-9]{16,24}
keys.docFrontstringYesThe R2 key returned by /init for the front of document. Max 512 chars
keys.selfiestringYesThe R2 key returned by /init for the selfie. Max 512 chars
keys.docBackstringNoThe R2 key for the back of document, if your flow captured it
livenessScorenumberNoOptional liveness score from MediaPipe (0-100). Used as additional signal
metadataobjectNoOptional key-value pairs for your bookkeeping. Echoed in webhooks

The keys.* values must match exactly what /init returned. Submitting arbitrary keys is rejected (doc_front_key_mismatch, selfie_key_mismatch, doc_back_key_mismatch).

Example request

curl

curl -X POST https://api.xxuxe.online/v1/verify/submit \
-H "Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9" \
-H "Content-Type: application/json" \
-d '{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"keys": {
"docFront": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/doc-front.jpg",
"selfie": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/selfie.jpg"
},
"livenessScore": 92.5,
"metadata": {
"campaign": "spring_2026",
"platform": "web"
}
}'

JavaScript / Node.js

const response = await fetch('https://api.xxuxe.online/v1/verify/submit', {
method: 'POST',
headers: {
'Authorization': `Bearer ${publishableKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
verificationId: initResponse.verificationId,
keys: {
docFront: initResponse.uploads.docFront.key,
selfie: initResponse.uploads.selfie.key,
},
livenessScore: 92.5,
}),
});

if (response.status === 202) {
const data = await response.json();
console.log('Submitted, poll at:', data.statusUrl);
}

Python

import os
import requests

response = requests.post(
"https://api.xxuxe.online/v1/verify/submit",
headers={
"Authorization": f"Bearer {os.environ['VERIDIA_PUBLISHABLE_KEY']}",
"Content-Type": "application/json",
},
json={
"verificationId": init_response["verificationId"],
"keys": {
"docFront": init_response["uploads"]["docFront"]["key"],
"selfie": init_response["uploads"]["selfie"]["key"],
},
"livenessScore": 92.5,
},
)
response.raise_for_status()
data = response.json()
print("Status URL:", data["statusUrl"])

PHP

<?php
$payload = json_encode([
"verificationId" => $initResponse['verificationId'],
"keys" => [
"docFront" => $initResponse['uploads']['docFront']['key'],
"selfie" => $initResponse['uploads']['selfie']['key'],
],
"livenessScore" => 92.5,
]);

$ch = curl_init("https://api.xxuxe.online/v1/verify/submit");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $_ENV['VERIDIA_PUBLISHABLE_KEY'],
"Content-Type: application/json",
]);

$data = json_decode(curl_exec($ch), true);
curl_close($ch);
echo "Status URL: " . $data['statusUrl'];

Response

202 Accepted

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "queued",
"statusUrl": "https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2"
}

Response fields

FieldTypeDescription
verificationIdstringSame ID you submitted — confirms the request was accepted
statusstringCurrent state: queued, processing, or completed
statusUrlstringWhere to poll for the verdict (typically /v1/verify/:id)

The verification is now in the pipeline. The verdict typically comes back in 2-3 seconds. Use the statusUrl to fetch it.

Idempotency

Calling /submit twice with the same verificationId is safe. The backend deduplicates by verificationId, so you'll get the same result both times. The OCR result is cached for 30 minutes — retries don't re-trigger Workers AI calls.

This means: if your client gets a network error after sending /submit, retry with the same body. You won't double-bill or create duplicate verifications.

Pipeline behind the scenes

When /submit is called, here's what happens (the API returns immediately at step 5 — the rest is async):

StepLatencyWhat
1<50msValidate request body (Zod schema)
2<100msLook up intent from KV cache
3<200msVerify R2 objects exist via HEAD calls
4<2sRun OCR via Workers AI (cached on retry)
5<200msDispatch to ML backend, return 202
6asyncBackend runs face match + liveness + verdict
7asyncBackend persists to MySQL + fires webhook

Total time from /submit to verdict: typically 2-3 seconds.

Errors

HTTPError codeWhen
400invalid_bodyRequest body failed validation. See detail.fieldErrors
400invalid_body (with reason: "doc_front_key_mismatch")The keys.docFront doesn't match what /init returned
400invalid_body (with reason: "selfie_key_mismatch")The keys.selfie doesn't match what /init returned
400invalid_body (with reason: "doc_front_not_uploaded")The R2 object doesn't exist — client never uploaded it
400invalid_body (with reason: "selfie_not_uploaded")Same, for the selfie
404verification_not_foundThe verificationId was never created via /init, or the intent expired (>1h)
401unauthorized / invalid_keyAuth issue. See Authentication
403insufficient_creditsTenant has 0 credits
429rate_limitedHit the /submit rate limit (30 req/min by default)
500internalSomething broke. Include the requestId in your support ticket

Full catalog: Errors.

Notes

  • The verificationId must have been created within the last hour. After that, the intent expires and you get verification_not_found
  • The same API key used in /init must be used here. Cross-tenant submission is rejected
  • The livenessScore is optional but recommended — even a basic browser-side score improves verdict accuracy
  • metadata is opaque to Veridia — it's just echoed in webhooks. Use it for things like A/B test buckets, campaign IDs, or platform tags

What's next

Now that the verification is in the pipeline, fetch the verdict:

GET /v1/verify/:id