Getting Started

πŸ“˜
This guide is specific to Web SDK 2.0. If you are still using 1.x, you can find documentation here. Contact your Incode Representative for upgrade information and check if you are a candidate for this upgrade.

Full rollout to all clients still TBD.

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.

No, just drop it in

Yes

Yes: own the shell, use Incode's screens

No: fully custom UI

Need identity verification?
Want any control over the UI?

Path 1 Path 2 Path 3
UI you own None Shell only Everything
UI Incode owns Everything Module screens None
Lines of code ~5 ~30 ~50+
Flow logic Internal Orchestrator Manual or orchestrator
Best for Fastest TTV, standard branding Custom shell, Incode's tested module UX Bespoke UI, native shells, accessibility customizations
Branding Theme tokens only Full shell, theme tokens for modules Full

Prerequisites

Before you begin, ensure you have:

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

Bash

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 });

Which path is right for me?

Path 1: <incode-flow> Path 2: Orchestrator + components Path 3: Headless
UI you own None Shell only Everything
UI Incode owns Everything Module screens None
Lines of code ~5 ~30 ~50+
Flow logic Internal Orchestrator Manual or orchestrator
Best for Fastest TTV, standard branding Custom shell, Incode's tested module UX Bespoke UI, native shells, accessibility customizations
Branding Theme tokens only Full shell, theme tokens for modules Full

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.

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

Field Type Required Notes
token string βœ… Session token from createSession()
lang string Locale, e.g. 'en-US', 'es-MX'
enableHome boolean Show the SDK's built-in home screen
authHint string QR/auth hint when re-entering a flow
wasmConfig WasmConfig Custom WASM paths (see WASM Configuration)
spinnerConfig { title?, subtitle?, size? } Customize the loading spinner copy
onFlowEvent (event) => void Stream of flow milestones (flow.ready, flow.module.completed, etc.)
onModuleLoading / onModuleLoaded (moduleKey) => void Hooks 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

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.

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:

Method Purpose
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';
};
scoreStatus Meaning
OK Passed all checks
WARN Passed with minor concerns
MANUAL_OK Needs manual review, leaning approve
MANUAL_FAIL Needs manual review, leaning reject
FAIL Failed verification
UNKNOWN Status 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

Goal Where to look
See every module the SDK ships Individual Modules
Reference for the <incode-flow> component IncodeFlow Component
Per-module headless API Headless Mode
Customize colors and typography Theming & Styling
Configure WASM hosting WASM Configuration
Reduce bundle size Bundle Optimization
Localize the SDK Internationalization
Subscribe to flow events Event Callbacks
Handle errors and edge cases Troubleshooting

Updated 3 days ago