First verification
Time to run a real verification with the widget you embedded in the previous step.
What you're about to test
A complete user flow:
- User clicks Start
- Browser asks for camera permission
- User photographs the front of their document (camera or gallery)
- User photographs the back — unless you set
require-doc-back="false"or the document type ispassport - User takes a selfie (camera only)
- Widget runs quality checks, uploads each image to the Veridia API, then calls submit
- You receive a
veridia:completeevent carrying theverificationId - The verdict (
approved/review/rejected) is computed server-side moments later
Run it
Open the page where you embedded the widget. Use a real device with a camera — the widget is mobile-first but works on laptops too.
Nothing to configure. Keys are created with an empty allowed-origins list, and an empty list permits every origin — localhost included. If the widget refuses to start, the cause is elsewhere; check e.detail.code on the veridia:error event.
Tips for the document capture
- Hold the document flat on a contrasting surface (avoid white-on-white)
- Don't cover any corners with your fingers
- Avoid direct light reflecting off the document — this raises the
heavy_glareflag - Make sure the document is fully in frame
- Hold the phone steady; blurry frames are rejected by the quality check before upload
Tips for the selfie
- Face the camera directly
- Good even lighting (no backlight)
- Remove sunglasses and hats that cover the face
- Stay still for the capture
Inspect the event payload
Add listeners to see exactly what the widget emits. These two are the only events the widget dispatches:
<script>
const w = document.querySelector('veridia-widget');
w.addEventListener('veridia:complete', (e) => {
console.log('verification complete:', e.detail);
});
w.addEventListener('veridia:error', (e) => {
console.error('verification error:', e.detail);
});
</script>
When the user finishes, your console shows exactly two fields:
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "queued"
}
That's it. There is no userRef in this event, and no verdict.
- No
userRef: the widget does not copy it into the event, andGET /v1/verify/{id}does not return it either. Only the webhook echoes it. PersistverificationId → your user idfrom this handler, or you will have a verdict you can't attribute. - No verdict:
status: "queued"describes the pipeline, not the person. Fetch the verdict from your server (step 3) or receive it by webhook.
See it in your dashboard
Go to your Veridia dashboard and open the Review queue. You'll see your verification with:
- Submitter info (user ref, submitted timestamp)
- The six scores: OCR confidence, face match, liveness, document quality, MRZ validity, and name match
- Flags, each with a level and a text (e.g.
heavy_glare,possible_screen_capture) - The overall confidence and the final verdict
Click into a verification to see the full breakdown and the captured images.
What happens behind the scenes
| Step | Where | What |
|---|---|---|
| 1 | Browser | Quality checks (Laplacian, Tenengrad, Brenner) before anything is sent |
| 2 | Browser | One PUT per image to /v1/verify/upload/{verificationId}/{role} on the Veridia API |
| 3 | Worker | Validates the upload token, checks the bytes are real JPEG, stores them |
| 4 | Worker | OCR via Workers AI — extracts name, document number, dates |
| 5 | Worker | Dispatches to backend with the extracted data |
| 6 | Backend | Face match (insightface buffalo_s) |
| 7 | Backend | Liveness scoring |
| 8 | Backend | MRZ checksums and MRZ-vs-visual-zone consistency |
| 9 | Backend | AML / sanctions screening |
| 10 | Backend | Weighted confidence score, then verdict |
| 11 | Backend | Persists to MySQL with audit trail |
| 12 | Backend | Queues the webhook, if one is configured |
You may see older material describing presigned uploads straight to Cloudflare R2. That is not what happens. Every image is PUT to the Veridia API itself, authenticated by a short-lived X-Veridia-Upload-Token that /v1/verify/init returns inside each upload slot's headers. The API validates and stores the bytes.
This matters in two places. If you are tightening a Content Security Policy, the host you need to allow is api.xxuxe.online, not an R2 domain — allowlisting R2 will block your uploads. And if you are ever writing a client by hand instead of using the widget, you must forward the slot's headers verbatim; your API key does not authenticate that endpoint, and building the request yourself without the upload token returns a 400 on every image.
What the verdict means
| Verdict | Roughly when | What you should do |
|---|---|---|
approved | Confidence ≥ 90 and no hard failures | Trust the user, complete onboarding |
review | Confidence between 60 and 90, or any hard failure, or a sanctions hit | Send to your manual review queue |
rejected | Confidence below 60 | Block, ask the user to retry, or escalate |
Two rules worth internalizing, because they are not visible from the confidence number alone:
- A hard failure never auto-approves. No face on the document, no face on the selfie, a failed MRZ checksum, a detected spoof — any of these forces at least
review, whatever the score says. - A sanctions match forces
review, never an automatic rejection. A person is expected to make that call.
These thresholds are deployment-wide settings, not per-tenant knobs. Do not reimplement the banding on your side from confidence; read verdict and act on it.
Error codes the widget emits
These are the real values of e.detail.code on veridia:error. The whole list:
| Code | Meaning | Typical response |
|---|---|---|
camera_denied | User denied the camera permission prompt | Explain why you need it, offer a retry |
camera_unavailable | No camera present, or the page is not on HTTPS/localhost | Tell the user to switch device or browser |
user_cancelled | User pressed Cancel and left the flow | Not an error. Log it — this is your funnel drop-off signal |
invalid_api_key | Key missing, mistyped, revoked, or wrong environment | Fix your config. Not user-facing |
insufficient_credits | Your tenant is out of credits | Top up. Alert yourself — every user is blocked until you do |
rate_limited | Rate limit hit | Back off and retry |
api_unreachable | Network failure or 5xx from the API | Transient. The widget already retried |
upload_failed | An image upload failed after three retries with backoff | Surface it; usually a bad mobile connection |
no_face_in_selfie | No face detected in the selfie | Reserved. Normally handled inline as a retake prompt |
blurry_image | Image too blurry | Reserved. Normally handled inline as a retake prompt |
internal_error | Anything unexpected, including unmapped API errors | Log the whole e.detail and investigate |
Two things about that table are easy to get wrong:
camera_denied and user_cancelled are the ones you'll actually see most. Together they account for most abandoned flows in real traffic. Neither is a bug, and both need a product answer rather than an error screen.
Blur and "no face" are usually not events. The widget handles those inline: it tells the user to retake, and after two consecutive failures on the same shot it lets the photo through anyway rather than trapping the user in a loop. So don't build your image-quality telemetry on these codes — it will stay empty. upload_failed, by contrast, does reach your handler once the retries are exhausted, so wire up an alert for it.
Next step
You have a verification result. Now learn how to read the verdict.