POST /v1/verify/submit
Once the client has uploaded the document and selfie, call /submit to run the verification pipeline.
POST https://api.xxuxe.online/v1/verify/submit
This call:
- Confirms the submitted keys are the ones
/initissued for this verification - Confirms the images actually landed in storage
- Runs OCR on the document via Workers AI
- Dispatches the job to the ML backend for face match, liveness and verdict
- Returns
202 Accepted
Everything after step 5 is asynchronous.
Authentication
Bearer token. Any key belonging to the same tenant as the one used on /init.
Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9
The check is on the tenant, not on the key. So a legitimate and often preferable pattern works: start the verification in the browser with your publishable key, then submit from your own server with your secret key. Cross-tenant submission is rejected.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
verificationId | string | Yes | From /init. Must match ^vf_[A-Za-z0-9]{16,24}$ |
keys.docFront | string | Yes | The key from init.uploads.docFront. Max 512 chars |
keys.selfie | string | Yes | The key from init.uploads.selfie. Max 512 chars |
keys.docBack | string | No | The key from init.uploads.docBack, if you captured it |
livenessScore | number | No | Client-side liveness score, 0-100. Used as an additional soft signal |
metadata | object | No | Accepted by the schema. See the warning below |
keys is mandatory, and it is not decorative
keys is what ties the stored bytes to this verification. There is no way to submit without it.
The values must match exactly what /init returned. Take them from the init response rather than reconstructing the strings — a mismatch is rejected with doc_front_key_mismatch, selfie_key_mismatch or doc_back_key_mismatch.
metadata is accepted and then discardedThe schema validates metadata, and the request returns 202. But the field is not forwarded to the backend and it is not echoed in webhooks. It stops at the edge of the Worker.
This is the worst kind of failure — it takes your data, succeeds, and drops it. If you were planning to route on a campaign ID, an A/B bucket or a brand tag, that will not work.
Use userRef on /init instead. It is persisted, and it is the one field echoed in webhook payloads.
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
}'
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: init.verificationId,
keys: {
docFront: init.uploads.docFront.key,
selfie: init.uploads.selfie.key,
// docBack: init.uploads.docBack.key, // only if you captured it
},
livenessScore: 92.5,
}),
});
const data = await response.json(); // 202 Accepted
console.log('Submitted. Poll:', 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["verificationId"],
"keys": {
"docFront": init["uploads"]["docFront"]["key"],
"selfie": init["uploads"]["selfie"]["key"],
},
"livenessScore": 92.5,
},
)
response.raise_for_status()
print("Poll:", response.json()["statusUrl"])
Response
202 Accepted
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "queued",
"statusUrl": "https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2"
}
| Field | Type | Description |
|---|---|---|
verificationId | string | Same ID you submitted |
status | string | queued, processing, or completed |
statusUrl | string | Where to poll for the verdict |
status here is not a verdictstatus: "completed" on this response — or on any later poll — means the pipeline finished. It does not mean the person passed. The outcome lives in a separate field, verdict, and it is not in this response at all.
Reading the verdict requires a secret key on GET /v1/verify/:id. The publishable key that made this call cannot fetch it.
Idempotency
Calling /submit twice with the same verificationId is safe.
The backend deduplicates: if the verification is already processing or completed, the duplicate is ignored and you get the current status back rather than a second pipeline run. A verification still queued or failed may be re-enqueued.
The OCR result is cached for 30 minutes, so a retry does not re-hit Workers AI either.
So if your client gets a network error after sending /submit, retry with the same body. You will not create a duplicate verification.
Authentication rejects requests when the tenant's credit balance is zero (insufficient_credits, 402), but this endpoint does not currently decrement that balance per verification. Do not build usage forecasting or a consumption meter on the assumption that one submit equals one credit off the counter — reconcile against your own records instead.
Timing
The 202 typically comes back in a couple of seconds; the OCR call dominates. The verdict itself lands a few seconds after that, asynchronously.
Rather than polling for it, use webhooks. If you must poll, GET /v1/verify/:id has the pattern — and note that polling from a single server hits the per-IP rate limit long before the per-tenant one.
Errors
| HTTP | Error code | detail.reason | When |
|---|---|---|---|
400 | invalid_body | — | Body failed validation. See detail.fieldErrors |
400 | invalid_body | doc_front_key_mismatch | keys.docFront isn't what /init returned |
400 | invalid_body | selfie_key_mismatch | keys.selfie isn't what /init returned |
400 | invalid_body | doc_back_key_mismatch | keys.docBack isn't what /init returned |
400 | invalid_body | doc_front_not_uploaded | No object at that key — the client never uploaded |
400 | invalid_body | selfie_not_uploaded | Same, for the selfie |
400 | invalid_body | doc_front_disappeared | Object existed at check time but was gone before OCR (very rare) |
401 | missing_api_key / invalid_api_key | — | See Authentication |
402 | insufficient_credits | — | Tenant balance is zero |
404 | verification_not_found | — | Never created via /init, expired after 1 hour, or belongs to another tenant |
429 | rate_limited | — | 30/min per tenant, or the per-IP limit |
500 | internal_error | — | Report the requestId |
503 | backend_unavailable | — | Pipeline unreachable. Retry with backoff |
insufficient_credits is 402, not 403. The internal error code is internal_error, not internal. Full catalog: Errors.
If you get doc_front_not_uploaded while believing you uploaded, the usual cause is an upload that failed with missing_upload_token because the client rebuilt the headers instead of forwarding slot.headers verbatim. See Uploading the images.
Notes
- The verification must have been created within the last hour. The intent expires after that and you get
verification_not_found. livenessScoreis optional. It is a soft signal that nudges the weighted confidence; it never decides the outcome on its own.- Capture provenance is read from server-stamped metadata written when the bytes arrived, not from this request body.
/submitcannot rewrite it.
What's next
GET /v1/verify/:id → — fetch the verdict