Concepts
Web SDK
The VerifyMe Web SDK opens the hosted, tenant-branded flow inside your page as a modal, an inline frame, a popup or a full-page redirect, and reports progress through events. One script tag, no dependencies, no build step.
Installation
<script src="https://kyc.example.com/sdk/v1/verifyme.js"></script>
The script defines the global window.VerifyMe (it is also safe to require() in a CommonJS bundler). It is served with Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin, so it works with crossorigin and strict cross-origin isolation setups. It uses ES5 syntax and runs in all evergreen browsers.
The SDK is a UX layer, not a trust boundary
Your server creates the request with a secret key and passes onboardingUrl to the page. The SDK callbacks tell your UI what the applicant is doing; they can be forged by anyone who controls the browser. Decide outcomes only from webhooks and GET /v1/onboarding-requests/{id}/result.
Quick start
VerifyMe.init({
publicKey: "vm_pk_test_xxxxxxxxxxxxxxxxxx",
onComplete: (r) => console.log("submitted", r.requestId, r.status),
onClose: (c) => console.log("closed", c.reason),
onError: (e) => console.warn(e.code, e.message),
});
document.getElementById("verify").addEventListener("click", () => {
VerifyMe.open({ url: onboardingUrlFromYourServer, mode: "modal" });
});
init(options)
VerifyMe.init(options) configures the SDK and returns VerifyMe. It is idempotent: calling it again merges the new options into the old ones.
| Option | Type | Description |
|---|---|---|
publicKey | string | Your workspace publishable key (vm_pk_...). The hosted UI refuses links that do not belong to this workspace |
host | string | VerifyMe origin. Defaults to the origin the SDK script was loaded from. Set it when your onboardingUrl is on a different origin, such as a custom domain |
onEvent | function | Receives every event as { type, data } |
onComplete | function | Applicant submitted: { requestId, status, externalReference } |
onClose | function | The dialog or popup closed: { reason } |
onError | function | SDK or hosted-UI error: { code, message } |
theme | object | { overlayColor?, radius? } for the modal backdrop and panel. The form itself is branded by your tenant branding settings |
zIndex | number | Stacking order of the modal. Default 2147483000 |
locale | string | Hint for the hosted UI language (English only today) |
fallback | boolean | Default true. When framing is blocked, fall back to popup and then redirect |
open(options)
VerifyMe.open(options) starts the flow and returns true if it was started. Failures are never thrown; they arrive as error events.
| Option | Description |
|---|---|
url | The onboardingUrl from POST /v1/onboarding-requests. Its origin must equal the SDK host, otherwise ORIGIN_MISMATCH. Must be an onboarding link (/start/...) |
token | Alternative to url: the raw vm_t_... token |
mode | modal (default), inline, popup, redirect |
container | Element or CSS selector. Required for inline. Give it a height of at least 640 px |
returnUrl | Optional URL on your site to navigate to after the applicant finishes (modal or popup). requestId, status and externalReference are appended as hints. For redirect mode set returnUrl when creating the request |
Modes
| Mode | What happens | Use when |
|---|---|---|
modal | Accessible full-screen dialog (on wide screens a centred panel) containing the hosted flow in an iframe | Default for web apps |
inline | The same iframe inside your container | The flow is a page section of its own |
popup | window.open with the hosted flow. Must be called from a click handler | Framing is not possible on your page |
redirect | location.assign(url). The applicant returns to the returnUrl set on the request | Simplest, most robust; mobile browsers |
If the hosted page does not report ready within 8 seconds (typically because framing is blocked for your origin), the SDK emits error FRAME_TIMEOUT, then fallback { to: "popup" } and, if the popup is blocked, fallback { to: "redirect" }, so the applicant is never left on a blank frame. Set fallback: false to handle this yourself.
Other methods
| Method | Description |
|---|---|
VerifyMe.close() | Closes the dialog or popup. Fires onClose({ reason: "api" }) |
VerifyMe.destroy() | Closes everything, removes listeners and resets configuration. No callbacks fire. Call init again before reuse. Use in component unmount |
VerifyMe.getState() | { version, initialized, open, mode, host, status, requestId, lastEvent } where status is idle, loading, ready, completed, closed or error |
VerifyMe.on(type, fn) / VerifyMe.off(type, fn?) | Event emitter. Use type "event" for all events. Omit fn in off to remove all handlers of a type |
VerifyMe.version | The SDK version string, "1.0.0" |
Events
Events arrive through onEvent, the specific callbacks (onComplete, onClose, onError) and VerifyMe.on(type, fn).
type | data | When |
|---|---|---|
ready | { requestId, status } | Hosted UI loaded and the handshake completed |
step | { stepKey, stepType, status: "completed", completed, total } | The applicant completed a step |
submitted | { requestId, status: "SUBMITTED", externalReference } | The applicant submitted. Also calls onComplete and emits the alias complete |
closed | { reason } | The dialog or popup was closed. Reasons include user, escape, completed, terminal, api, popup-closed |
error | { code, message } | An SDK or hosted-UI problem |
fallback | { to } (popup or redirect) | The SDK switched to a different mode after a frame timeout |
redirect | { url } | The SDK is about to navigate (token redacted in url) |
popup-detached | { message } | The popup's state cannot be observed (cross-origin isolation). Rely on webhooks |
{ "type": "step", "data": { "stepKey": "pan", "stepType": "PanVerification", "status": "completed", "completed": 3, "total": 6 } }
Error codes
| Code | Meaning |
|---|---|
INVALID_HOST | host is not an http(s) origin |
INVALID_MODE | mode is not one of the four values |
INVALID_URL / INVALID_TOKEN / MISSING_TARGET | url is not an onboarding link, the token is malformed, or neither was given |
ORIGIN_MISMATCH | The url origin differs from the configured host |
MISSING_CONTAINER | inline mode without a container |
POPUP_BLOCKED | The browser blocked the popup. Open from a click handler |
FRAME_TIMEOUT | The hosted page did not respond in 8 seconds. Framing may be blocked |
LINK_INVALID, SESSION_EXPIRED, or a final request status such as EXPIRED, CANCELLED, REJECTED, APPROVED | Reported by the hosted UI when the link or session cannot continue (for example the applicant reopened a finished request) |
SDK_ERROR | An unexpected internal error, caught so it never reaches your app |
postMessage protocol
You normally never touch this; the SDK does it. It is documented so you can audit it or embed the iframe yourself.
- The parent (SDK) sends
{ source: "verifyme-host", type: "init", origin: <your page origin> }to the hosted page, repeatedly (every 400 ms, up to 25 times) until it receivesready. The target origin is the VerifyMe origin. - The hosted page checks that
event.sourceis its parent (or its opener, for popups) and thatevent.originequals the origin the message claims. Only then does it remember that origin and post only to it. Before the handshake it posts only to thedocument.referrerorigin, if there is one. - The hosted page sends
{ source: "verifyme", type, ...data }wheretypeisready,step,submitted,closedorerror. - The SDK accepts a message only if
event.originequals the configured host origin andevent.sourceis its own iframe or popup window, andsource === "verifyme".
// Minimal listener if you embed the iframe yourself
window.addEventListener("message", (e) => {
if (e.origin !== "https://kyc.example.com") return;
if (e.source !== iframe.contentWindow) return;
const m = e.data;
if (!m || m.source !== "verifyme") return;
if (m.type === "submitted") showPending(m.requestId);
});
// ...and complete the handshake so the page knows where to send messages:
iframe.contentWindow.postMessage(
{ source: "verifyme-host", type: "init", origin: location.origin },
"https://kyc.example.com"
);
Framing, CSP and permissions
| Concern | Requirement |
|---|---|
| Allowed origins | Your page origin must be on the portal's Allowed origins list. VerifyMe sets frame-ancestors to that list for ?embed=1 links; everyone else is refused. In production an empty list blocks framing. Resolution uses the host the link is served on, so use your tenant (custom) domain in production |
| Your CSP | script-src must allow the SDK origin and frame-src the VerifyMe origin. The SDK sets styles through the DOM API and needs no inline <style> |
| iframe attributes (set by the SDK) | allow="camera; geolocation; microphone; clipboard-write", referrerpolicy="no-referrer", sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" |
| Cookies | The hosted UI does not depend on third-party cookies in embed mode: it uses a session header returned for embedded sessions |
| Referrer | no-referrer. The onboarding URL token is stripped of unknown query parameters by the hosted page after load |
| Permissions policy | Your own Permissions-Policy must not disable camera, geolocation or microphone for the VerifyMe origin |
| HTTPS | Camera and location need a secure context. Use HTTPS in production |
Popup versus modal versus redirect
| Modal | Popup | Redirect | |
|---|---|---|---|
| Stays on your page | Yes | Partly | No |
| Needs framing allowed | Yes | No | No |
| Events back to your page | Yes | Often not (COOP isolation) | No. Use returnUrl and webhooks |
| Works in in-app browsers | Mixed | Often blocked | Best |
| Camera inside | Needs allow (SDK sets it) | Native | Native |
Accessibility
The modal is an accessible dialog: focus moves into it and is trapped, background content is made inert and page scroll is locked, Esc closes it (the hosted page forwards Esc pressed inside the frame as closed with reason escape), focus returns to the control that opened it, and safe-area insets are respected on notched phones. The SDK close button is hidden once the hosted UI shows its own. The frame has the title "Identity verification". Provide a visible, labelled button that opens the flow, and announce completion to assistive technology yourself in onComplete.
Versioning, SRI and migration
- The script path is versioned:
/sdk/v1/verifyme.js. Breaking changes will ship under/sdk/v2/;v1only receives backwards compatible fixes. The file is cacheable for about five minutes. - Subresource Integrity. The
v1file can change with compatible fixes, so an SRI hash on it will eventually break. If your policy requires SRI, self-host a copy you have reviewed:openssl dgst -sha384 -binary verifyme.js | openssl base64 -A, then<script src="/static/verifyme.js" integrity="sha384-..." crossorigin="anonymous"></script>. - Migrating from a plain redirect: keep your server code and
returnUrl. Add the script and replaceres.redirect(onboardingUrl)withVerifyMe.open({ url, mode: "modal" }). Leave the webhook handling untouched. - Migrating from your own iframe: remove your
postMessagecode, delete theallowattributes you set and callVerifyMe.open. Add your origin to Allowed origins.
Complete example
Three files: a tiny Express server that creates the request, an HTML page and an external script (inline scripts would violate a strict CSP).
// npm i express (Node 18+)
import express from "express";
const HOST = process.env.VERIFYME_HOST; // https://kyc.example.com
const KEY = process.env.VERIFYME_API_KEY; // vm_test_... (secret, server only)
const app = express();
app.use(express.static("public"));
app.post("/api/kyc/start", async (req, res) => {
const r = await fetch(`${HOST}/v1/onboarding-requests`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `demo-${Date.now()}`,
},
body: JSON.stringify({ externalReference: `demo-${Date.now()}`, mobile: "9876541234" }),
});
if (!r.ok) return res.status(502).json({ error: "could_not_start" });
const { requestId, onboardingUrl } = await r.json();
res.json({ requestId, onboardingUrl }); // never send the API key to the browser
});
app.listen(3000, () => console.log("http://localhost:3000"));<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verify your identity</title>
</head>
<body>
<main>
<h1>Verify your identity</h1>
<button id="verify" type="button">Start verification</button>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script src="https://kyc.example.com/sdk/v1/verifyme.js"></script>
<script src="/kyc.js"></script>
</body>
</html>const statusEl = document.getElementById("status");
const button = document.getElementById("verify");
VerifyMe.init({
publicKey: "vm_pk_test_xxxxxxxxxxxxxxxxxx",
onEvent: ({ type, data }) => console.debug("[verifyme]", type, data),
onComplete: ({ requestId }) => {
statusEl.textContent = "Thanks. We are verifying your details.";
button.hidden = true;
// Optional: tell your backend so it can start polling or wait for the webhook.
fetch("/api/kyc/submitted", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId }),
});
},
onClose: ({ reason }) => {
if (reason !== "completed") statusEl.textContent = "Verification was not finished.";
},
onError: ({ code, message }) => {
statusEl.textContent = "Something went wrong. Please try again.";
console.warn(code, message);
},
});
button.addEventListener("click", async () => {
button.disabled = true;
statusEl.textContent = "";
try {
const r = await fetch("/api/kyc/start", { method: "POST" });
if (!r.ok) throw new Error("start failed");
const { onboardingUrl } = await r.json();
VerifyMe.open({ url: onboardingUrl, mode: "modal" });
} catch (e) {
statusEl.textContent = "Could not start verification.";
} finally {
button.disabled = false;
}
});A runnable sandbox demo is available at /sdk-demo/ on your VerifyMe sandbox host: paste an onboardingUrl, try the four modes and watch the events live.