Verification
Web SDK
@thirdfactor/web — open the hosted flow in a popup, cross-origin modal, or full-page redirect and resolve one typed result.
@thirdfactor/web opens the hosted verification flow in a popup, a cross-origin modal iframe, or a full-page redirect, listens to the flow over the shared bridge, and resolves a single typed VerificationResult. It is a thin shell: your backend creates the session and gets a hosted url; the browser hands that url to the SDK.
Warning
The client result is advisory. Grant access or move money only after your backend confirms the decision from the signed webhook (identity.kyc.session.approved / .declined / .review / .expired) or GET /v3/session/<id>/decision/.
Requirements
| Browser | Any modern evergreen browser (Chromium, Firefox, Safari) with postMessage + camera (getUserMedia). |
| Bundler | ESM. Ships .js + .d.ts; works with Vite, webpack, Next.js, etc. |
| Backend (for the session helper) | Node 18+ (global fetch) or any runtime with a WHATWG fetch. |
| TypeScript | Optional; full types are bundled. |
Installation
npm install @thirdfactor/webThe package has two entry points, deliberately separated so your browser bundle never imports anything that touches your API key:
import { ThirdFactor } from "@thirdfactor/web"; // browser
import { createSession } from "@thirdfactor/web/server"; // server onlyQuick start
Create a session on your backend
Session creation uses your secret tenant API key and must never run in the browser.
// server-side (Node 18+)
import { createSession } from "@thirdfactor/web/server";
const session = await createSession(
{ baseUrl: process.env.TF_BASE_URL!, apiKey: process.env.TF_API_KEY! },
{
vendorData: user.id, // your reference for this user
callbackUrl: "https://app.acme.com/kyc/done",
contactEmail: user.email,
prefill: { full_name: user.name },
idempotencyKey: `kyc-${user.id}`, // safe retries, never double-charged
},
);
// send { url: session.url, id: session.id } to the browserOpen the flow from a real click
import { ThirdFactor } from "@thirdfactor/web";
button.addEventListener("click", async () => {
const result = await ThirdFactor.verify({
url: session.url, // from step 1
mode: "auto", // default; resolves to popup
onEvent: (e) => console.log(e.type), // ready | completed | error | close
});
switch (result.status) {
case "approved": showSuccess(); break; // then confirm server-side
case "declined": showFailure(); break;
case "in_review": showPending(); break;
case "cancelled": break; // user closed the flow
case "error": showError(result.error); break;
}
});Warning
Call verify() from a genuine click or tap. Popup mode is blocked by the browser otherwise (you'll get error.code === "popup_blocked").
Confirm server-side
The browser result is a UX signal only. Grant access after your backend confirms the signed webhook or GET /v3/session/<id>/decision/.
Presentation modes
| Mode | Call | Best for | Notes |
|---|---|---|---|
auto (default) | verify({ url }) | Anything | Resolves to popup — a top-level context with reliable camera everywhere. |
popup | verify({ url, mode: "popup" }) | Mobile web, zero-config | New window; most reliable camera. |
modal | verify({ url, mode: "modal" }) | Desktop, in-page UX | Cross-origin iframe. Opt-in — the flow deployment must allowlist your origin (see below). |
redirect | ThirdFactor.redirect(url) | Max compatibility | Full-page navigate; does not return a Promise. Result arrives via callback_url + webhook. |
Note
auto deliberately resolves to popup, not modal — camera in a cross-origin iframe is unreliable on iOS Safari, and a top-level context works with the flow's default headers everywhere.
API reference
ThirdFactor.verify(options): Promise<VerificationResult>
Never rejects — always resolves a VerificationResult.
urlstringbodyrequiredHosted session URL from POST /v3/session/.
mode"modal" | "popup" | "auto"bodyDefault: "auto"Presentation mode. auto = popup. modal is opt-in (see Enabling modal).
onEvent(e: VerificationEvent) => voidbodyCalled for every lifecycle event: ready / completed / error / close.
titlestringbodyDefault: "Identity verification"Accessible aria-label for the modal dialog.
autoClosebooleanbodyDefault: truefalse keeps the flow's own result screen up until the user taps Done.
expectedOriginstringbodyDefault: origin of urlOnly override behind a proxy that serves the flow on a different origin.
ThirdFactor.redirect(url: string): void
Full-page navigation to the flow. Does not return. The flow returns the user to the session's callback_url on completion; read the decision from your backend on that return page. Best camera compatibility.
VerificationResult
interface VerificationResult {
status: "approved" | "declined" | "in_review" | "cancelled" | "error";
sessionId?: string; // echoed from the flow when available
error?: { code: string; message: string }; // present only when status === "error"
rawStatus?: string; // raw server status (e.g. "abandoned")
}VerificationEvent
The low-level, per-message stream passed to onEvent. One VerificationResult is still resolved separately once the surface settles.
type VerificationEvent =
| { type: "ready" }
| { type: "completed"; status: string; sessionId?: string }
| { type: "error"; code: string; message?: string }
| { type: "close" };Complete API reference
Every exported symbol from @thirdfactor/web and @thirdfactor/web/server, verified against sdks/web/src/:
| Export | Entry point | Type | Notes |
|---|---|---|---|
ThirdFactor | @thirdfactor/web (default + named) | { verify, redirect } | Namespace object; import { ThirdFactor } from "@thirdfactor/web". |
verify(options) | @thirdfactor/web | (VerifyOptions) => Promise<VerificationResult> | Never rejects. |
redirect(url) | @thirdfactor/web | (string) => void | Full-page navigation; does not return. |
VerifyOptions | @thirdfactor/web (type) | interface | See table above. |
VerifyMode | @thirdfactor/web (type) | "modal" | "popup" | "auto" | |
VerificationResult | @thirdfactor/web (type) | interface | See above. |
VerificationStatus | @thirdfactor/web (type) | "approved" | "declined" | "in_review" | "cancelled" | "error" | |
VerificationError | @thirdfactor/web (type) | { code: string; message: string } | |
VerificationEvent | @thirdfactor/web (type) | discriminated union | See above. |
BRIDGE_SOURCE | @thirdfactor/web | "thirdfactor" (const) | The envelope discriminator every bridge message carries — exported for hosts building their own transport handling. |
normalizeStatus(raw) | @thirdfactor/web | (string) => VerificationStatus | The exact mapping the SDK applies internally; exposed for advanced/debug use. |
parseBridgeMessage(data) | @thirdfactor/web | (unknown) => BridgeMessage | null | Low-level envelope parser/validator; returns null for anything that isn't a well-formed source: "thirdfactor" message. |
createSession(config, input) | @thirdfactor/web/server | (CreateSessionConfig, CreateSessionInput) => Promise<Session> | Server-only; see below. |
CreateSessionConfig | @thirdfactor/web/server (type) | { baseUrl, apiKey, fetchImpl? } | |
CreateSessionInput | @thirdfactor/web/server (type) | interface | See table above. |
Session | @thirdfactor/web/server (type) | interface | { id, status, url, decision, vendorData?, expiresAt?, createdAt?, raw }. |
ThirdFactorApiError | @thirdfactor/web/server | class extends Error | .status: number, .code: string. Thrown by createSession on a non-2xx response. |
Note
fetchImpl on CreateSessionConfig lets you override the fetch implementation used by createSession — useful in tests or when your runtime needs a custom agent (e.g. a corporate proxy). It defaults to globalThis.fetch; if neither is available, createSession throws ThirdFactorApiError(0, "no_fetch", ...).
The server helper: createSession(config, input)
@thirdfactor/web/server wraps POST /v3/session/ so you don't hand-roll the request. It runs only on your server.
config:{ baseUrl, apiKey, fetchImpl? }input(all fields optional):
| Field | Type | Maps to POST /v3/session/ | Notes |
|---|---|---|---|
workflowId | string | workflow_id | KYC workflow UUID; tenant default when omitted. |
vendorData | string | vendor_data | Your external user reference (also the linked identity's external id). |
callbackUrl | string | callback_url | Where the applicant is redirected after finishing. |
contactEmail / contactPhone | string | contact_email / contact_phone | OTP + notifications. |
expiresInHours | number | expires_in_hours | Hosted-URL lifetime, 1..720 (default 48). |
locale | string | locale | Hosted-flow language (default en). |
prefill | Record<string,string> | prefill | Applicant fields to pre-populate (only documented keys honored). |
idempotencyKey | string | Idempotency-Key header | Repeat creates return the original session, uncharged. |
The helper returns the session object ({ id, url, status, decision, expiresAt, ... }). On a non-2xx response it throws ThirdFactorApiError with .status and a stable .code (e.g. insufficient_credits).
Note
The helper is optional. POST /v3/session/ is a single authenticated call — create the session however you like (any language/runtime) and pass the returned url to ThirdFactor.verify({ url }). The browser side is identical either way.
Enabling modal (cross-origin iframe)
modal embeds the flow in an iframe on your origin. For that to work, the flow deployment must allowlist your app origin — otherwise the browser refuses the frame (X-Frame-Options) and denies the iframe camera (Permissions-Policy). On the Obsidian flow deployment set:
FLOW_EMBED_ORIGINS="https://app.acme.com https://staging.acme.com"This adds your origins to the flow route's frame-ancestors and grants them camera/microphone. Every other route keeps X-Frame-Options: DENY. With FLOW_EMBED_ORIGINS unset (the default), cross-origin modal is refused by design — use auto (popup) or redirect.
Warning
Even correctly allowlisted, camera in a cross-origin iframe is unreliable on iOS Safari. That's why auto uses popup. If your app has a strict Content-Security-Policy, your own frame-src must also permit the flow origin when you use modal.
Error handling
status === "error" carries a stable error.code — branch on it, never on error.message:
error.code | Cause |
|---|---|
invalid_session | Token is not a valid session. |
expired | Session/URL expired. |
popup_blocked | verify() was not called from a user gesture. |
load_error | The flow surface failed to load. |
missing_url | options.url was empty. |
no_window | verify() ran outside a browser. |
network / unknown | Transport failure / unrecognized error. |
const result = await ThirdFactor.verify({ url: session.url });
if (result.status === "error") {
if (result.error?.code === "expired") reMintSession();
else showError(result.error);
}Language, prefill, and metadata
The hosted flow's language, copy, prefill, and workflow are chosen at session-create time, not in the browser SDK:
- Locale — pass
localetocreateSession(or"locale"in the rawPOST /v3/session/body). The SDK renders whatever the flow serves; no per-platform i18n code. - Prefill — pass
prefillto pre-populate the flow (full_name,first_name,last_name,display_name,date_of_birth,email,phone,nationality,address_line1/2,city,state,country,zip_code). Unknown keys are ignored. - Your reference —
vendorDatabecomes the session's external reference and the linked identity'sexternal_user_id; it comes back on the session and every webhook.
Browser-side advanced knobs are just expectedOrigin (proxy override) and autoClose.
NFC on the web
The web SDK has no NFC module. Reading an ePassport / eID chip requires the device NFC radio, which a browser cannot reach. When a workflow includes an NFC_VERIFICATION step, the web host advertises no nfc capability, so the flow's server-side fallback applies (the NFC step resolves to manual review). NFC is a native-only capability — see the NFC deep-dive and the iOS, Android, and Flutter SDKs.
How it works
verify() opens the hosted flow with ?embed=1&sdk=web appended. In embed mode the flow reports lifecycle over postMessage instead of redirecting. The SDK validates every message against the flow's origin and the thirdfactor source tag before acting. The same bridge protocol backs the Flutter, iOS, and Android SDKs — see the bridge protocol.
Full end-to-end example
A complete Next.js-style integration: a server route that mints the session, and a client component that opens it and reports the result.
// Runs ONLY on the server — imports @thirdfactor/web/server, which never
// ships to the browser bundle.
import { createSession, ThirdFactorApiError } from "@thirdfactor/web/server";
export async function createKycSession(userId: string, email: string) {
try {
const session = await createSession(
{
baseUrl: process.env.TF_BASE_URL!,
apiKey: process.env.TF_API_KEY!,
},
{
vendorData: userId,
callbackUrl: `${process.env.APP_URL}/kyc/done`,
contactEmail: email,
expiresInHours: 24,
prefill: { email },
idempotencyKey: `kyc-${userId}`,
},
);
return { url: session.url, id: session.id };
} catch (err) {
if (err instanceof ThirdFactorApiError) {
// e.g. err.code === "insufficient_credits"
throw new Error(`Could not start verification: ${err.code}`);
}
throw err;
}
}"use client";
import { useState } from "react";
import { ThirdFactor, type VerificationResult } from "@thirdfactor/web";
export function VerifyButton({ userId }: { userId: string }) {
const [status, setStatus] = useState<VerificationResult["status"] | "idle">("idle");
async function handleClick() {
// Fetch the session URL from YOUR API, which calls createKycSession()
// server-side. Never call createSession() from the browser.
const res = await fetch("/api/kyc/session", { method: "POST" });
const { url } = await res.json();
const result = await ThirdFactor.verify({
url,
mode: "auto",
onEvent: (e) => console.log("[kyc]", e.type),
});
setStatus(result.status);
switch (result.status) {
case "approved":
// UX only — the page can now show a "verified, pending confirmation"
// state. Grant real access once your backend's webhook handler flips
// the user's server-side status.
break;
case "declined":
break;
case "in_review":
break;
case "cancelled":
break;
case "error":
if (result.error?.code === "expired") {
// re-fetch a fresh session URL and let the user retry
}
console.error(result.error);
break;
}
}
return (
<button onClick={handleClick} disabled={status === "approved"}>
{status === "idle" ? "Verify identity" : `Status: ${status}`}
</button>
);
}Troubleshooting
Warning
error.code === "popup_blocked". verify() was not called synchronously from a genuine user gesture (click/tap handler). A common mistake is fetching the session URL inside the click handler and only opening the popup after the await resolves in a way the browser no longer attributes to the gesture. Fix: mode: "auto"/"popup" must be invoked directly in response to the click; if you must fetch the URL first, await the fetch and confirm you're still inside a "trusted" call chain the browser recognizes — in practice this is rarely an issue if the whole async handler is the click listener itself, only if you defer to a setTimeout or an unrelated async callback first.
Warning
modal mode silently refuses to open, or the camera never appears in the iframe. modal needs the flow deployment to explicitly allowlist your origin via FLOW_EMBED_ORIGINS. Without it, the browser's X-Frame-Options/CSP refuses the frame outright. Confirm with the operator that your exact origin (scheme + host + port) is in that list, and that your own app's CSP frame-src also permits the flow's origin.
Warning
Nothing happens after approved — access isn't actually granted. This is almost always a missing server-side confirmation step. The client result is advisory; your backend must independently confirm the decision via the signed webhook or GET /v3/session/<id>/decision/ before flipping any real permission. Don't gate access directly on result.status === "approved".
Warning
TypeError: createSession is not a function (or similar) in the browser console. You imported createSession from @thirdfactor/web instead of @thirdfactor/web/server, or bundled server code into a client component. The two entry points are separated on purpose — @thirdfactor/web/server must never end up in a browser bundle (it also protects you from accidentally leaking TF_API_KEY into client JS).