Widget events
The widget emits exactly two events on the host element. There are no others — no veridia:start, no veridia:step, no veridia:cancel. Cancellation arrives as an error code, not as its own event.
Both are CustomEvents dispatched with bubbles: true and composed: true, so you can listen on the element itself or on any ancestor (including document).
veridia:complete
Fires after the images were uploaded and POST /v1/verify/submit returned successfully.
const widget = document.querySelector('veridia-widget');
widget.addEventListener('veridia:complete', (e) => {
console.log(e.detail);
// { verificationId: "vf_AG07CDWRRFQV4T05ZXG2", status: "queued" }
});
e.detail has exactly these fields:
| Field | Type | Always | Description |
|---|---|---|---|
verificationId | string | Yes | The vf_* id of this verification |
status | "queued" | "processing" | "completed" | Yes | Pipeline state at submit time |
verdict | "approved" | "review" | "rejected" | No | Never set by the widget itself |
There is no userRef in this event
If you passed user-ref, it is not echoed here. Earlier versions of this page claimed it was; that was wrong, and code written against it silently associated verdicts with undefined.
userRef travels in the webhook payload. It is not returned by GET /v1/verify/{id} either. If you need to map a verification back to your own user from the browser, store the mapping yourself the moment this event fires:
widget.addEventListener('veridia:complete', async (e) => {
await fetch('/api/kyc/started', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// YOU own this association. The widget will not hand it back to you.
body: JSON.stringify({ verificationId: e.detail.verificationId, userId: currentUser.id }),
});
});
status is not a verdict
status is the pipeline axis: queued → processing → completed (or failed). verdict is the outcome axis: approved / review / rejected.
status: "completed" means the pipeline ran. It says nothing about whether the person passed. Activating an account when status reaches completed admits every rejected applicant — this is the most expensive mistake the API makes available, and it is easy to make because the word sounds like success.
Get the verdict from the webhook or from a server-side GET /v1/verify/{id} with a secret key.
veridia:error
Fires when the flow stops in a way the user cannot recover from inside the widget. The widget switches to its error screen and stops.
widget.addEventListener('veridia:error', (e) => {
const { code, message, detail } = e.detail;
console.error(`[veridia] ${code}: ${message}`, detail);
});
e.detail has exactly these fields:
| Field | Type | Always | Description |
|---|---|---|---|
code | string | Yes | Machine-readable code from the list below |
message | string | Yes | English diagnostic text — for your logs, not your UI |
detail | object | No | Present only when the error came from the API |
message is not localized and is not written for end users. The widget already shows the user a localized message; use message for logging and support.
Error codes
These are the real values of code. Switch on these strings.
| Code | Cause | Emitted as an event |
|---|---|---|
camera_denied | User denied camera permission (NotAllowedError) | Yes |
camera_unavailable | No camera device, or getUserMedia failed for any other reason | Yes |
upload_failed | An image upload failed after all retries | Yes |
api_unreachable | Network failure, 5xx, or backend_unavailable from the API | Yes |
invalid_api_key | Missing, malformed, unknown, or revoked publishable key | Yes |
insufficient_credits | The tenant's balance is 0 (API returns 402) | Yes |
rate_limited | Rate limit hit (API returns 429) | Yes |
user_cancelled | The user pressed Cancel | Yes |
internal_error | Anything else, including invalid_body from the API | Yes |
blurry_image | Frame too soft | No — see below |
no_face_in_selfie | No face found in the selfie | No — see below |
blurry_image and no_face_in_selfie exist in the public VeridiaErrorCode type, but the widget never dispatches them. They are quality-check reasons rendered inline on the review screen, asking the user to retake. Do not build telemetry that waits for them — it will stay empty forever.
Two of the reachable codes dominate real traffic and are the ones most integrations forget:
camera_denied— a routine outcome, not an anomaly. On mobile Safari a denial is sticky: re-mounting the widget will not re-prompt. Show your own instructions for re-enabling the permission in browser settings, and offer an alternative path.user_cancelled— the user pressed Cancel. This is abandonment, not a fault. Do not log it as an error, do not alert on it, and do not show a failure screen. Count it: it is your funnel drop-off metric.
About upload_failed
The widget already retries a failed upload up to 3 times with backoff (400 ms, 1200 ms), and only for transient failures — network drop, timeout, abort, 5xx. A permanent 4xx fails immediately.
So by the time upload_failed reaches your handler, the retries are spent. This code means a real, persistent network problem for that user. Instrument it — it is the signal that tells you a whole region or carrier is failing.
detail is only present for API errors
When the failure came from the Veridia API, detail carries the API's detail object verbatim (for example retry_after on a 429). When the failure is client-side (camera_denied, user_cancelled), there is no detail key at all. Check before reading it.
A complete handler
const widget = document.querySelector('veridia-widget');
widget.addEventListener('veridia:complete', async (e) => {
// Submission accepted. NOT a verdict. Hand the id to your backend.
await fetch('/api/kyc/submitted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verificationId: e.detail.verificationId }),
});
showPendingScreen();
});
widget.addEventListener('veridia:error', (e) => {
const { code, message, detail } = e.detail;
switch (code) {
case 'user_cancelled':
// Abandonment, not a failure. Metric, no alert, no error UI.
analytics.track('kyc_abandoned');
showRestartPrompt();
break;
case 'camera_denied':
// Sticky on iOS Safari — re-mounting will not re-prompt.
showCameraPermissionHelp();
break;
case 'camera_unavailable':
showMessage('We could not find a camera on this device.');
offerDesktopToMobileHandoff();
break;
case 'upload_failed':
case 'api_unreachable':
// Retries already exhausted. Real connectivity problem.
alerting.warn('veridia_network', { code, message });
showMessage('Connection problem. Please try again.');
break;
case 'rate_limited':
// detail.retry_after is seconds, when the API supplied it.
showMessage('Too many attempts. Please wait a moment.');
alerting.warn('veridia_rate_limited', { retryAfter: detail?.retry_after });
break;
case 'invalid_api_key':
case 'insufficient_credits':
// YOUR problem, not the user's. Page someone.
alerting.critical('veridia_config', { code, message });
showMessage('Verification is temporarily unavailable.');
break;
default:
// internal_error and anything added in future versions.
alerting.error('veridia_unknown', { code, message });
showMessage('Something went wrong. Please try again.');
}
});
Keep the default branch. If a future widget version adds a code, this handler degrades to a generic message instead of silently doing nothing.
What's next
- Configuration — every attribute and its real default.
- Examples — these handlers wired into complete integrations.
- Webhooks — where the verdict actually arrives.