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

BrowserAny modern evergreen browser (Chromium, Firefox, Safari) with postMessage + camera (getUserMedia).
BundlerESM. 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.
TypeScriptOptional; full types are bundled.

Installation

npm install @thirdfactor/web

The 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 only

Quick 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 browser

Open 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

ModeCallBest forNotes
auto (default)verify({ url })AnythingResolves to popup — a top-level context with reliable camera everywhere.
popupverify({ url, mode: "popup" })Mobile web, zero-configNew window; most reliable camera.
modalverify({ url, mode: "modal" })Desktop, in-page UXCross-origin iframe. Opt-in — the flow deployment must allowlist your origin (see below).
redirectThirdFactor.redirect(url)Max compatibilityFull-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.

urlstringbodyrequired

Hosted 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) => voidbody

Called for every lifecycle event: ready / completed / error / close.

titlestringbodyDefault: "Identity verification"

Accessible aria-label for the modal dialog.

autoClosebooleanbodyDefault: true

false keeps the flow's own result screen up until the user taps Done.

expectedOriginstringbodyDefault: origin of url

Only 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/:

ExportEntry pointTypeNotes
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) => voidFull-page navigation; does not return.
VerifyOptions@thirdfactor/web (type)interfaceSee table above.
VerifyMode@thirdfactor/web (type)"modal" | "popup" | "auto"
VerificationResult@thirdfactor/web (type)interfaceSee above.
VerificationStatus@thirdfactor/web (type)"approved" | "declined" | "in_review" | "cancelled" | "error"
VerificationError@thirdfactor/web (type){ code: string; message: string }
VerificationEvent@thirdfactor/web (type)discriminated unionSee 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) => VerificationStatusThe exact mapping the SDK applies internally; exposed for advanced/debug use.
parseBridgeMessage(data)@thirdfactor/web(unknown) => BridgeMessage | nullLow-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)interfaceSee table above.
Session@thirdfactor/web/server (type)interface{ id, status, url, decision, vendorData?, expiresAt?, createdAt?, raw }.
ThirdFactorApiError@thirdfactor/web/serverclass 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):
FieldTypeMaps to POST /v3/session/Notes
workflowIdstringworkflow_idKYC workflow UUID; tenant default when omitted.
vendorDatastringvendor_dataYour external user reference (also the linked identity's external id).
callbackUrlstringcallback_urlWhere the applicant is redirected after finishing.
contactEmail / contactPhonestringcontact_email / contact_phoneOTP + notifications.
expiresInHoursnumberexpires_in_hoursHosted-URL lifetime, 1..720 (default 48).
localestringlocaleHosted-flow language (default en).
prefillRecord<string,string>prefillApplicant fields to pre-populate (only documented keys honored).
idempotencyKeystringIdempotency-Key headerRepeat 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.codeCause
invalid_sessionToken is not a valid session.
expiredSession/URL expired.
popup_blockedverify() was not called from a user gesture.
load_errorThe flow surface failed to load.
missing_urloptions.url was empty.
no_windowverify() ran outside a browser.
network / unknownTransport 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 locale to createSession (or "locale" in the raw POST /v3/session/ body). The SDK renders whatever the flow serves; no per-platform i18n code.
  • Prefill — pass prefill to 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 referencevendorData becomes the session's external reference and the linked identity's external_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).

FAQ