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
| Parameter | Type | Description |
|---|---|---|
:id | string | The 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.
| Field | Question it answers | Values |
|---|---|---|
status | Did the pipeline run? | queued, processing, completed, failed |
verdict | Did 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
}
null, not absentWhile 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
| Field | Type | Present | Description |
|---|---|---|---|
verificationId | string | Always | The verification ID |
status | string | Always | queued, processing, completed, failed |
verdict | string | null | null until completed | approved, review, rejected |
confidence | number | null | null until completed | Overall weighted score, 0-100 |
scores | object | null | null until completed | Signal breakdown — see below |
flags | array | null | null until completed | Objects { level, text } |
submittedAt | string | Always | ISO 8601 UTC, when /submit was called |
completedAt | string | null | null until terminal | ISO 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.
| Key | Range | Meaning |
|---|---|---|
ocr_confidence | 0-100 | The extraction model's self-reported confidence in the document text |
face_match | 0-100 | Biometric similarity between selfie and document photo |
liveness | 0-100 or null | Anti-spoofing signal |
doc_quality | 0-100 | Document image quality (sharpness, glare, resolution, moiré) |
mrz_valid | 0-100 | Machine-readable zone checksum validity |
name_match | 0-100 | Fuzzy 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:
| Level | Meaning |
|---|---|
ok | A check passed. This is a positive signal, not a problem |
warn | Something is off but not disqualifying |
err | A 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 alwaysfalse. Serious signals areerr. A reviewer-priority rule written againstcriticalnever fires, and the cases that most need a human get normal priority.- A non-empty
flagsarray does not mean something is wrong. Every approved verification carries at least{ "level": "ok", "text": "auto_approved_all_checks_passed" }. Treatingflags.length > 0as "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
text | Typical level | Meaning |
|---|---|---|
auto_approved_all_checks_passed | ok | Approved with no hard failures. Always first when present |
image_blurry | warn | Sharpness below threshold |
heavy_glare | warn | Reflective glare obscuring the document |
possible_screen_capture | warn | Moiré pattern — the "document" may be a photo of a screen |
low_resolution | warn | Image resolution too low |
mrz_checksums_valid | ok | MRZ checksums verified |
mrz_checksum_failed | warn / err | MRZ present but checksums do not verify |
mrz_viz_consistent | ok | MRZ agrees with the printed fields |
mrz_viz_mismatch | err | Valid MRZ that contradicts the printed fields — strong forgery signal |
no_face_detected_on_document | err | No face found in the document photo |
no_face_detected_on_selfie | err | No face found in the selfie |
face_match_below_critical_threshold | err | Selfie and document photo are very unlikely to be the same person |
document_quality_unusable | err | Document image too degraded to assess |
active_liveness_spoof | err | The active-liveness challenge concluded the capture was not live |
active_liveness_live | ok | The challenge passed |
aml_sanctions_match | err | Strong match against an official sanctions list |
aml_possible_match | warn | Weaker sanctions match, worth a look |
missing_images | err | Expected 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
| Verdict | Condition |
|---|---|
approved | confidence >= 90, and no hard failures, and no compliance hold |
review | Anything in between — including every case with a hard failure that isn't catastrophic |
rejected | confidence < 60 |
Three rules that the numbers alone do not tell you:
- A hard failure never auto-approves, whatever the score. Any
err-level hard failure caps the outcome atreview, orrejectedif the confidence is also below 60. - 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. - The band 60-89 is
review, notapproved. 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
| Status | Meaning |
|---|---|
queued | Waiting in the backend queue |
processing | Pipeline running |
completed | Pipeline finished. Now read verdict |
failed | Pipeline 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.
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
| HTTP | Error code | When |
|---|---|---|
401 | missing_api_key | No Authorization header |
401 | invalid_api_key | Key revoked, malformed, or never existed |
401 | secret_key_required | A publishable key was used. The most common error on this endpoint |
404 | verification_not_found | ID doesn't exist, is malformed, or belongs to another tenant |
429 | rate_limited | Per-IP or per-tenant limit |
500 | internal_error | Report the requestId |
503 | backend_unavailable | Backend 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
completedorfailed, the response for a given automated run is stable — but areviewcase can change later when a human resolves it. If you cache, invalidate on the webhook. submittedAtandcompletedAtare ISO 8601 in UTC. (expiresAton/initis different — that one is Unix seconds.)userRefis not returned here. It travels only in webhooks. Keep your ownverificationId→ 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