Skip to main content

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:

FieldTypeAlwaysDescription
verificationIdstringYesThe vf_* id of this verification
status"queued" | "processing" | "completed"YesPipeline state at submit time
verdict"approved" | "review" | "rejected"NoNever 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: queuedprocessingcompleted (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:

FieldTypeAlwaysDescription
codestringYesMachine-readable code from the list below
messagestringYesEnglish diagnostic text — for your logs, not your UI
detailobjectNoPresent 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.

CodeCauseEmitted as an event
camera_deniedUser denied camera permission (NotAllowedError)Yes
camera_unavailableNo camera device, or getUserMedia failed for any other reasonYes
upload_failedAn image upload failed after all retriesYes
api_unreachableNetwork failure, 5xx, or backend_unavailable from the APIYes
invalid_api_keyMissing, malformed, unknown, or revoked publishable keyYes
insufficient_creditsThe tenant's balance is 0 (API returns 402)Yes
rate_limitedRate limit hit (API returns 429)Yes
user_cancelledThe user pressed CancelYes
internal_errorAnything else, including invalid_body from the APIYes
blurry_imageFrame too softNo — see below
no_face_in_selfieNo face found in the selfieNo — 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.