Skip to main content

GET /v1/verify/:id

Fetch the current state of a verification, and the verdict once the pipeline has finished.

GET https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2

Authentication

This endpoint requires a secret key. A publishable key is rejected with 401 secret_key_required, even though the same key was allowed to create and submit the verification.

Authorization: Bearer qv_sec_YOUR_SECRET_KEY

In test mode the prefix is qv_sect_.

The reason is not arbitrary: a publishable key ships inside your page source, where anyone can read it. If it could read verdicts, every customer's KYC outcome — including their document number and date of birth — would be one fetch away from anyone with developer tools open. So results are server-side only, by construction.

If you need a browser to know the flow finished, the widget's veridia:complete event tells it that the images were submitted. It deliberately does not carry the verdict. Decide on the server.

Path parameter

ParameterTypeDescription
:idstringThe verificationId from /init, e.g. vf_AG07CDWRRFQV4T05ZXG2

status and verdict are different axes

Read this before writing any branching logic. It is the most expensive mistake this API allows.

FieldQuestion it answersValues
statusDid the pipeline run?queued, processing, completed, failed
verdictDid the person pass?approved, review, rejected

status: "completed" means the machinery finished its work. Every rejected applicant also reaches completed — that is what a successful rejection looks like.

// WRONG — this onboards every applicant the system rejected.
// It throws no error, logs nothing unusual, and looks fine in testing
// as long as your test users all pass.
if (data.status === 'completed') {
await enableAccount(userId);
}

// RIGHT — the two axes checked separately
if (data.status === 'completed') {
if (data.verdict === 'approved') await enableAccount(userId);
else if (data.verdict === 'review') await queueForManualReview(userId);
else if (data.verdict === 'rejected') await blockOnboarding(userId);
}

A second trap in the same family: review is a final verdict, not a transitional one. The pipeline is done; a human now has to act. Polling and waiting for review to resolve itself waits forever. The resolution arrives as a new webhook event when a reviewer decides.

Example request

curl

curl -X GET https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sec_YOUR_SECRET_KEY"

JavaScript / Node.js

const response = await fetch(
`https://api.xxuxe.online/v1/verify/${verificationId}`,
{ headers: { 'Authorization': `Bearer ${process.env.VERIDIA_SECRET_KEY}` } }
);
const data = await response.json();
console.log(data.status, data.verdict);

Python

import os
import requests

response = requests.get(
f"https://api.xxuxe.online/v1/verify/{verification_id}",
headers={"Authorization": f"Bearer {os.environ['VERIDIA_SECRET_KEY']}"},
)
response.raise_for_status()
data = response.json()
print(data["status"], data.get("verdict"))

Response

200 OK

While processing:

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "processing",
"verdict": null,
"confidence": null,
"scores": null,
"flags": null,
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": null
}
The keys are present with null, not absent

While a verification is in flight, verdict, confidence, scores, flags and completedAt are returned as JSON null — the keys exist.

So 'verdict' in data is true from the very first poll, and data.verdict !== undefined is true too. Neither is a valid "is it done?" test. Check status, or check the value for null.

In typed clients this matters more: a field modelled as a non-optional string will fail to deserialize on the first poll.

When complete:

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "completed",
"verdict": "approved",
"confidence": 93.4,
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 97.0
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" },
{ "level": "ok", "text": "mrz_checksums_valid" }
],
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": "2026-05-01T18:39:08Z"
}

Needing manual review:

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "completed",
"verdict": "review",
"confidence": 64.5,
"scores": {
"ocr_confidence": 88.0,
"face_match": 71.2,
"liveness": null,
"doc_quality": 55.0,
"mrz_valid": 0.0,
"name_match": 62.0
},
"flags": [
{ "level": "warn", "text": "mrz_checksum_failed" },
{ "level": "warn", "text": "heavy_glare" }
],
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": "2026-05-01T18:39:08Z"
}

Response fields

FieldTypePresentDescription
verificationIdstringAlwaysThe verification ID
statusstringAlwaysqueued, processing, completed, failed
verdictstring | nullnull until completedapproved, review, rejected
confidencenumber | nullnull until completedOverall weighted score, 0-100
scoresobject | nullnull until completedSignal breakdown — see below
flagsarray | nullnull until completedObjects { level, text }
submittedAtstringAlwaysISO 8601 UTC, when /submit was called
completedAtstring | nullnull until terminalISO 8601 UTC

scores — the keys are snake_case

Six keys, all snake_case. This is the field integrators most often get wrong, because the mistake is silent.

KeyRangeMeaning
ocr_confidence0-100The extraction model's self-reported confidence in the document text
face_match0-100Biometric similarity between selfie and document photo
liveness0-100 or nullAnti-spoofing signal
doc_quality0-100Document image quality (sharpness, glare, resolution, moiré)
mrz_valid0-100Machine-readable zone checksum validity
name_match0-100Fuzzy match between submittedFullName and the OCR'd name

Two things to be careful with:

There is no faceMatch. In JavaScript, scores.faceMatch is undefined, and undefined < 70 evaluates to false. So a threshold written in camelCase does not throw — it silently never fires, and every applicant passes your check. If you are porting a threshold from an older version of these docs, this is the line to fix.

liveness can be null. It is null when no liveness signal was produced or the liveness model errored. It is the only nullable member of scores. Guard it before doing arithmetic:

const liveness = data.scores.liveness;
if (liveness !== null && liveness < 50) {
// ...
}

mrz_valid and name_match are the two document-fraud signals most often overlooked. name_match in particular is the score produced by the submittedFullName you sent to /init — if you send that field, read this score.

flags

flags is an array of objects, not strings:

{ "level": "warn", "text": "heavy_glare" }

Levels

There are exactly three, and one of them means good:

LevelMeaning
okA check passed. This is a positive signal, not a problem
warnSomething is off but not disqualifying
errA serious signal — hard failure, sanctions match, or forgery indicator

There is no info and no critical. Two consequences worth stating:

  • flags.some(f => f.level === 'critical') is always false. Serious signals are err. A reviewer-priority rule written against critical never fires, and the cases that most need a human get normal priority.
  • A non-empty flags array does not mean something is wrong. Every approved verification carries at least { "level": "ok", "text": "auto_approved_all_checks_passed" }. Treating flags.length > 0 as "problem" sends 100% of your clean approvals to manual review.

Filter on level:

const problems = data.flags.filter(f => f.level !== 'ok');
const serious = data.flags.filter(f => f.level === 'err');

Flags you will actually see

textTypical levelMeaning
auto_approved_all_checks_passedokApproved with no hard failures. Always first when present
image_blurrywarnSharpness below threshold
heavy_glarewarnReflective glare obscuring the document
possible_screen_capturewarnMoiré pattern — the "document" may be a photo of a screen
low_resolutionwarnImage resolution too low
mrz_checksums_validokMRZ checksums verified
mrz_checksum_failedwarn / errMRZ present but checksums do not verify
mrz_viz_consistentokMRZ agrees with the printed fields
mrz_viz_mismatcherrValid MRZ that contradicts the printed fields — strong forgery signal
no_face_detected_on_documenterrNo face found in the document photo
no_face_detected_on_selfieerrNo face found in the selfie
face_match_below_critical_thresholderrSelfie and document photo are very unlikely to be the same person
document_quality_unusableerrDocument image too degraded to assess
active_liveness_spooferrThe active-liveness challenge concluded the capture was not live
active_liveness_liveokThe challenge passed
aml_sanctions_matcherrStrong match against an official sanctions list
aml_possible_matchwarnWeaker sanctions match, worth a look
missing_imageserrExpected images were not present at pipeline time

The three that carry regulatory or fraud weight and are easiest to miss: possible_screen_capture (image injection), mrz_viz_mismatch (fabricated document), and aml_sanctions_match (the person is on a sanctions list). Route those somewhere a human sees them.

Do not display flag text to your end user. Telling someone which check they failed tells an attacker exactly what to fix.

How the verdict is decided

VerdictCondition
approvedconfidence >= 90, and no hard failures, and no compliance hold
reviewAnything in between — including every case with a hard failure that isn't catastrophic
rejectedconfidence < 60

Three rules that the numbers alone do not tell you:

  1. A hard failure never auto-approves, whatever the score. Any err-level hard failure caps the outcome at review, or rejected if the confidence is also below 60.
  2. A strong sanctions match forces review. It downgrades an otherwise-approved case; it never auto-rejects. A sanctions hit is a compliance decision for a human, not a machine.
  3. The band 60-89 is review, not approved. If you are used to a threshold of 80, this is the gap that will fill your manual queue unexpectedly.

The thresholds are deployment-level settings, not per-tenant configuration — there is no per-customer dial for them today.

If you reimplement the threshold on your side by reading confidence, you will lose rules 1 and 2 and will auto-approve cases the system deliberately held back, including sanctions matches. Read verdict.

Status state machine

queued -> processing -> completed
-> failed
StatusMeaning
queuedWaiting in the backend queue
processingPipeline running
completedPipeline finished. Now read verdict
failedPipeline could not complete. There is no verdict — do not read one

failed is not a rejection. It means the system could not reach a conclusion. Treat it as a retry-or-escalate case, not as a decision about the applicant.

Polling

Webhooks are better: they fire as soon as the verdict is ready, and they also deliver the later event when a human resolves a review. Polling cannot see that second outcome unless you keep polling indefinitely. See Webhooks.

If you must poll:

async function waitForVerdict(verificationId, timeoutMs = 30000) {
const start = Date.now();
let interval = 1000; // 1s — see the rate-limit note below

while (Date.now() - start < timeoutMs) {
const response = await fetch(
`https://api.xxuxe.online/v1/verify/${verificationId}`,
{ headers: { 'Authorization': `Bearer ${process.env.VERIDIA_SECRET_KEY}` } }
);
const data = await response.json();

if (data.status === 'completed' || data.status === 'failed') {
return data; // caller must still branch on data.verdict
}

await new Promise(r => setTimeout(r, interval));
interval = Math.min(interval * 1.5, 3000);
}

throw new Error('Verification timed out');
}

Note the loop returns on either terminal status. The caller must handle failed, where verdict is null.

Do not poll faster than 1 Hz

The per-tenant limit here is 600/minute, but the per-IP limit is 100/minute and it is checked first. Polling from one server at 500 ms is 120 requests per minute and will be throttled long before the tenant limit. See Rate limits.

Errors

HTTPError codeWhen
401missing_api_keyNo Authorization header
401invalid_api_keyKey revoked, malformed, or never existed
401secret_key_requiredA publishable key was used. The most common error on this endpoint
404verification_not_foundID doesn't exist, is malformed, or belongs to another tenant
429rate_limitedPer-IP or per-tenant limit
500internal_errorReport the requestId
503backend_unavailableBackend unreachable — transient, retry with backoff

If you arrive here from /init and /submit reusing the same key and get a 401, check the code before you touch the key. secret_key_required means the key is perfectly valid — it is just the wrong family for this call. Rotating it will not help.

A verification that belongs to another tenant returns 404, not 403: a caller who does not own a verification should not learn that it exists.

Full catalog: Errors.

Notes

  • Verifications do not become unreadable over time. Ownership is checked against the tenant recorded with the verification itself, so this endpoint keeps working long after the one-hour upload intent has expired. That expiry applies to uploads and /submit, not to reading results.
  • Once completed or failed, the response for a given automated run is stable — but a review case can change later when a human resolves it. If you cache, invalidate on the webhook.
  • submittedAt and completedAt are ISO 8601 in UTC. (expiresAt on /init is different — that one is Unix seconds.)
  • userRef is not returned here. It travels only in webhooks. Keep your own verificationId → user mapping.

What's next

  • Webhooks — get verdicts pushed, including human review outcomes
  • Errors — full error code reference
  • Rate limits — why polling hits a limit sooner than you'd expect