Getting Started

šŸ“˜

This guide is specific to Web SDK 2.0. If you are still using 1.x, you can find documentation here. We strongly recommend upgrading - contact your Incode Representative for upgrade information.

This guide walks you through integrating the Incode Web SDK into your web application. It targets enterprise developers integrating Incode into their own product, and Incode Professional Services developers building custom integrations on a customer's behalf.

The SDK supports three integration paths — pick the one that matches how much UI control you need.

flowchart TD
    A[Need identity verification?] --> B{Want any control over the UI?}
    B -->|No, just drop it in| P1["<b>Path 1</b><br/>incode-flow web component<br/>~5 lines of code"]
    B -->|Yes| C{Want to use Incode's UI for the modules themselves?}
    C -->|Yes: own the shell, use Incode's screens| P2["<b>Path 2</b><br/>Orchestrator + module web components<br/>~30 lines of code"]
    C -->|No: fully custom UI| P3["<b>Path 3</b><br/>Headless Managers<br/>~50+ lines of code"]

    style P1 fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
    style P2 fill:#fef3c7,stroke:#d97706,color:#78350f
    style P3 fill:#fce7f3,stroke:#be185d,color:#831843

Prerequisites

Before you begin, ensure you have:

  • Incode Account: access to the Incode Dashboard
  • API Key: found in the dashboard under Settings → API Keys
  • Configuration ID: the flow ID from your dashboard configuration
  • Node.js 18+ and a modern bundler (Vite, Webpack, etc.) for module-aware imports

Production note: Never expose your API key in client-side code. Create sessions from your backend and pass only the resulting token to the browser. See Backend session creation below.

Install

npm install @incodetech/web @incodetech/core
# or
pnpm add @incodetech/web @incodetech/core
# or
yarn add @incodetech/web @incodetech/core

@incodetech/core is the framework-agnostic SDK core (state machines, managers, business logic). @incodetech/web is the UI layer — every consumer-facing element ships as a standard Web Component plus its CSS.

Initialize the SDK

Every integration path starts the same way: call setup(), create a session, then activate the session.

import { setup } from '@incodetech/core';
import { createSession, initializeSession } from '@incodetech/core/session';

// 1. Configure the SDK (apiURL, optional WASM/i18n/UI options)
await setup({ apiURL: 'https://demo-api.incodesmile.com' });

// 2. Create a session — in production this should happen on your backend (see below)
const session = await createSession('YOUR_API_KEY', {
  configurationId: 'YOUR_CONFIGURATION_ID',
  language: 'en-US',
});

// 3. Activate the session token
await initializeSession({ token: session.token });

Why three calls? setup() provisions the HTTP client and (optionally) warms up WASM; createSession opens the verification interview and returns a token; initializeSession activates the token and pre-loads session-scoped state (feature flags, device fingerprint). If your backend hands you the token directly, you can collapse steps 2 and 3 into one initializeSession({ token }) call. setup({ apiURL, token }) is also still supported as a one-shot convenience — it delegates to initializeSession for you — but the two-call form makes the boot / activation boundary explicit, which matters when you want to start setup() early (e.g. to warm up WASM) before the token is known.

End-to-end encryption. Pass encryption: true to setup() to opt into encrypted transport for every SDK request. It's independent of token and can be enabled before authentication. Requires the binary (WASM) transport — setup({ wasm: false, encryption: true }) throws. Once enabled, encryption is a one-way switch.

Not a self-serve flag — contact your Incode account team first. E2EE has to be provisioned for your account, and you'll be given a dedicated apiURL to point at (E2EE traffic is served from a different host than the regular API; pointing encryption: true at your standard apiURL will fail the handshake at setup()). Your account team will also tell you which mgf1 scheme the environment expects. See API Reference → setup() and WASM Configuration → End-to-end encryption for the full caveats.


Which path is right for me?

Path 1: <incode-flow>Path 2: Orchestrator + componentsPath 3: Headless
UI you ownNoneShell onlyEverything
UI Incode ownsEverythingModule screensNone
Lines of code~5~30~50+
Flow logicInternalOrchestratorManual or orchestrator
Best forFastest TTV, standard brandingCustom shell, Incode's tested module UXBespoke UI, native shells, accessibility customizations
BrandingTheme tokens onlyFull shell, theme tokens for modulesFull

Path 1: <incode-flow> drop-in

The simplest integration. Drop one custom element on the page, set its config, get a callback when verification finishes. Incode owns the entire UI: home screen, all module screens, transitions, completion.

HTML / vanilla JS

<!doctype html>
<html>
  <head>
    <style>
      html, body, #root, incode-flow { height: 100%; margin: 0; }
    </style>
  </head>
  <body>
    <div id="root"></div>

    <script type="module">
      import { setup } from '@incodetech/core';
      import { createSession, initializeSession } from '@incodetech/core/session';
      import '@incodetech/web/themes/light.css';
      import '@incodetech/web/base.css';
      import '@incodetech/web/flow';            // registers <incode-flow>
      import '@incodetech/web/flow/styles.css';

      await setup({ apiURL: 'https://demo-api.incodesmile.com' });

      const session = await createSession('YOUR_API_KEY', {
        configurationId: 'YOUR_CONFIGURATION_ID',
        language: 'en-US',
      });

      await initializeSession({ token: session.token });

      const flow = document.createElement('incode-flow');
      flow.config = {
        token: session.token,
        lang: 'en-US',
        enableHome: true,
      };
      flow.onFinish = (result) => {
        console.log('Verification complete:', result);
        // result.action: 'approved' | 'rejected' | 'none'
        // result.scoreStatus: 'OK' | 'WARN' | 'MANUAL_OK' | 'FAIL' | ...
        // result.redirectionUrl: string
      };
      flow.onError = (error, errorCode) => {
        console.error('Flow error:', error, errorCode);
      };

      document.getElementById('root').appendChild(flow);
    </script>
  </body>
</html>

React

<incode-flow> is a standard custom element, so you can use it from React directly. Set non-string properties (config, onFinish, onError) imperatively via a ref — JSX attributes only support strings.

React 18 or earlier: add the JSX augmentation from Framework Integration → TypeScript: JSX support for incode-* tags so TypeScript recognizes the Incode tags. React 19+ handles unknown lowercase tags automatically and can also use the simpler form from Framework Integration → React 19+ shortcut.

import { useEffect, useRef } from 'react';
import type { FlowConfig } from '@incodetech/web/flow';
import type { FinishStatus } from '@incodetech/core/flow';
import '@incodetech/web/themes/light.css';
import '@incodetech/web/base.css';
import '@incodetech/web/flow';
import '@incodetech/web/flow/styles.css';

type FlowElement = HTMLElement & {
  config: FlowConfig;
  onFinish: (result?: FinishStatus) => void;
  onError: (error: string | undefined, code?: number) => void;
};

export function VerificationFlow({ token }: { token: string }) {
  const ref = useRef<FlowElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.config = { token, lang: 'en-US', enableHome: true };
    el.onFinish = (result) => console.log('Done:', result);
    el.onError = (error, code) => console.error(error, code);
  }, [token]);

  return <incode-flow ref={ref} style={{ display: 'block', height: '100vh' }} />;
}

What <incode-flow> config accepts

FieldTypeRequiredNotes
tokenstringāœ…Session token from createSession()
langstringLocale, e.g. 'en-US', 'es-MX'
enableHomebooleanShow the SDK's built-in home screen
authHintstringQR/auth hint when re-entering a flow
wasmConfigWasmConfigCustom WASM paths (see WASM Configuration)
spinnerConfig{ title?, subtitle?, size? }Customize the loading spinner copy
onFlowEvent(event) => voidStream of flow milestones (flow.ready, flow.module.completed, etc.)
onModuleLoading / onModuleLoaded(moduleKey) => voidHooks around lazy-loaded module chunks

Self-loading variant: pass apiKey + configurationId instead of token and the component will create the session itself. Convenient for prototyping; avoid in production because it puts the API key in the browser.


Path 2: Orchestrator + module web components

You own the shell — landing page, transitions, completion screen — but Incode owns each module's UI. Use the orchestrator to drive flow progression and render Incode's module web components in your own layout.

import { useEffect, useState } from 'react';
import { setup } from '@incodetech/core';
import { createSession } from '@incodetech/core/session';
import {
  createOrchestratedFlowManager,
  type FlowModuleConfig,
  type OrchestratedFlowState,
} from '@incodetech/core/flow';
import { selfieMachine } from '@incodetech/core/selfie';
import { phoneMachine } from '@incodetech/core/phone';
import { emailMachine } from '@incodetech/core/email';
import { idCaptureMachine } from '@incodetech/core/id';
import '@incodetech/web/themes/light.css';
import '@incodetech/web/base.css';
import '@incodetech/web/selfie';
import '@incodetech/web/phone';
import '@incodetech/web/email';
import '@incodetech/web/id';

const flowManager = createOrchestratedFlowManager({
  modules: {
    SELFIE: selfieMachine,
    PHONE: phoneMachine,
    EMAIL: emailMachine,
    ID: idCaptureMachine,
  },
});

export function OrchestratedFlow({ token }: { token: string }) {
  const [state, setState] = useState<OrchestratedFlowState>(
    flowManager.getState(),
  );

  useEffect(() => {
    const unsubscribe = flowManager.subscribe(setState);
    flowManager.load();
    return () => {
      unsubscribe();
      flowManager.stop();
    };
  }, []);

  if (state.status === 'idle' || state.status === 'loading') {
    return <YourLoadingScreen />;
  }

  if (state.status === 'error') {
    return <YourErrorScreen message={state.error} onRetry={() => flowManager.reset()} />;
  }

  if (state.status === 'finished') {
    return <YourCompletionScreen result={state.finishStatus} />;
  }

  // status === 'ready' — render the current module
  const { currentStep, config } = state;

  if (currentStep === 'PHONE') {
    return (
      <incode-phone
        ref={(el) => {
          if (!el) return;
          el.config = config as FlowModuleConfig['PHONE'];
          el.onFinish = () => flowManager.completeModule();
          el.onError = (err) => console.error(err);
        }}
      />
    );
  }

  if (currentStep === 'SELFIE') {
    return (
      <incode-selfie
        ref={(el) => {
          if (!el) return;
          el.config = config as FlowModuleConfig['SELFIE'];
          el.onFinish = () => flowManager.completeModule();
          el.onError = (err) => console.error(err);
        }}
      />
    );
  }

  // ... handle EMAIL, ID, etc.
  return null;
}

What you control

  • The shell: your landing page, your branding, your loading and error states, your completion view
  • When the flow starts: call flowManager.load() only after the user clicks "Start"
  • When to advance: call flowManager.completeModule() on each module's onFinish
  • Reset / retry: flowManager.reset() returns to idle; the user can begin the flow again

Orchestrator state shape

Every variant carries homeScreen (visibility of the SDK's built-in home screen, when enabled) and presentation (UI hints used by <incode-flow> for transition timing). For Path 2 you can usually ignore these and let your shell own the loading/transition UX.

type OrchestratedFlowHomeScreen = {
  visible: boolean;
  isContinueLoading: boolean;
};

type OrchestratedFlowPresentation = {
  isAwaitingReady: boolean;
  lazyModuleKey: string | undefined;
  shouldPrefetchHome: boolean;
};

type OrchestratedFlowState =
  | { status: 'idle';
      homeScreen: OrchestratedFlowHomeScreen;
      presentation: OrchestratedFlowPresentation }
  | { status: 'loading';
      homeScreen: OrchestratedFlowHomeScreen;
      presentation: OrchestratedFlowPresentation }
  | { status: 'ready';
      flow: Flow;
      steps: string[];
      currentStepIndex: number;
      currentStep: string | undefined;
      config: unknown;
      moduleState: unknown;
      homeScreen: OrchestratedFlowHomeScreen;
      presentation: OrchestratedFlowPresentation }
  | { status: 'finished';
      flow: Flow;
      finishStatus: FinishStatus;
      homeScreen: OrchestratedFlowHomeScreen;
      presentation: OrchestratedFlowPresentation }
  | { status: 'error';
      error: string;
      homeScreen: OrchestratedFlowHomeScreen;
      presentation: OrchestratedFlowPresentation };

config and moduleState are typed as unknown because they vary by module. Cast via FlowModuleConfig[<step>] (e.g. FlowModuleConfig['PHONE']) when handing them to the corresponding component.

WASM warmup: Path 2 lets you load WASM only for modules that actually need it. Use getRequiredWasmPipelines(state.flow) (from @incodetech/core/flow) plus warmupWasm (from @incodetech/core/wasm) on the 'ready' transition. See WASM Configuration.


Path 3: Headless Managers

You own everything — including each module's UI. Use the per-module Manager APIs to drive state machines directly, subscribe to state, and render whatever interface you want (React Native via web view, AR overlay, server-rendered HTML, terminal UI, anything).

This is the most code, but also the most flexibility.

import { setup } from '@incodetech/core';
import { createSession, initializeSession } from '@incodetech/core/session';
import { createPhoneManager } from '@incodetech/core/phone';

await setup({ apiURL: 'https://demo-api.incodesmile.com' });

const session = await createSession('YOUR_API_KEY', {
  configurationId: 'YOUR_CONFIGURATION_ID',
});

await initializeSession({ token: session.token });

const phoneManager = createPhoneManager({
  config: {
    otpVerification: true,
    otpExpirationInMinutes: 5,
    prefill: false,
    maxOtpAttempts: 3,
  },
});

const unsubscribe = phoneManager.subscribe((state) => {
  switch (state.status) {
    case 'inputting':
      renderPhoneInputForm({
        countryCode: state.countryCode,
        onSubmit: (phone, isValid) => {
          phoneManager.setPhoneNumber(phone, isValid);
          phoneManager.submit();
        },
      });
      break;
    case 'awaitingOtp':
      renderOtpForm({
        canResend: state.canResend,
        timer: state.resendTimer,
        onSubmit: (otp) => phoneManager.submitOtp(otp),
        onResend: () => phoneManager.resendOtp(),
      });
      break;
    case 'finished':
      console.log('Phone verified!');
      unsubscribe();
      phoneManager.stop();
      break;
    case 'error':
      console.error('Phone error:', state.error);
      break;
  }
});

phoneManager.load();

Manager API surface (every module)

All managers follow the same shape:

MethodPurpose
getState()Snapshot of current state
subscribe(callback)Listen to state transitions; returns an unsubscribe function
load()Start the state machine
stop()Tear down — always call before unmount

Plus module-specific methods (e.g. setPhoneNumber, submit, submitOtp, requestPermission, capture, retryCapture, etc.). See Headless Mode for per-module API references and state shapes.

Composing modules headlessly

You can either drive each module manager directly (as above) or use createOrchestratedFlowManager without rendering any of Incode's UI components — subscribe to the orchestrator's state and render your own UI for every module. Same pattern as Path 2; you just don't import @incodetech/web/* at all.


Result handling

When verification finishes, you get a FinishStatus:

type FinishStatus = {
  redirectionUrl: string;
  action: 'approved' | 'rejected' | 'none';
  scoreStatus:
    | 'OK'
    | 'WARN'
    | 'MANUAL_OK'
    | 'MANUAL_FAIL'
    | 'FAIL'
    | 'UNKNOWN';
};
scoreStatusMeaning
OKPassed all checks
WARNPassed with minor concerns
MANUAL_OKNeeds manual review, leaning approve
MANUAL_FAILNeeds manual review, leaning reject
FAILFailed verification
UNKNOWNStatus not yet determined

onFinish on the client is a notification, not the source of truth. Always confirm the verification outcome from your backend by calling Incode's server API with the session token — client-side data can be tampered with.


Production: backend session creation

@incodetech/core is a frontend SDK and shouldn't be imported on the server. Instead, call Incode's REST API directly from your backend with whichever HTTP client your stack uses. The endpoint your backend hits is the same one the frontend createSession() helper wraps:

POST {apiURL}/omni/start
Headers:
  x-api-key: <your API key>
  api-version: 1.0
  content-type: application/json
Body:
  { "configurationId": "<flow ID>", "externalId": "<your user reference>", "language": "en-US" }

The response is JSON with at least { token, interviewId }. Forward only the token to the browser.

// Backend (Node.js 18+ — uses native fetch; works the same with axios or any HTTP client)
app.post('/api/incode/start', async (req, res) => {
  const response = await fetch('https://demo-api.incodesmile.com/omni/start', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.INCODE_API_KEY,
      'api-version': '1.0',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      configurationId: process.env.INCODE_CONFIG_ID,
      externalId: req.user.id, // link the session to your user
    }),
  });

  if (!response.ok) {
    return res.status(response.status).json({ error: 'Failed to create Incode session' });
  }

  const { token } = await response.json();
  res.json({ token });
});
// Frontend
const response = await fetch('/api/incode/start', { method: 'POST' });
const { token } = await response.json();

await setup({ apiURL: 'https://demo-api.incodesmile.com' });
await initializeSession({ token });
// proceed with Path 1, 2, or 3

Next steps

GoalWhere to look
See every module the SDK shipsIndividual Modules
Reference for the <incode-flow> componentIncodeFlow Component
Per-module headless APIHeadless Mode
Customize colors and typographyTheming & Styling
Configure WASM hostingWASM Configuration
Reduce bundle sizeBundle Optimization
Localize the SDKInternationalization
Subscribe to flow eventsEvent Callbacks
Handle errors and edge casesTroubleshooting