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;createSessionopens the verification interview and returns a token;initializeSessionactivates 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 oneinitializeSession({ token })call.setup({ apiURL, token })is also still supported as a one-shot convenience ā it delegates toinitializeSessionfor you ā but the two-call form makes the boot / activation boundary explicit, which matters when you want to startsetup()early (e.g. to warm up WASM) before the token is known.End-to-end encryption. Pass
encryption: truetosetup()to opt into encrypted transport for every SDK request. It's independent oftokenand 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
apiURLto point at (E2EE traffic is served from a different host than the regular API; pointingencryption: trueat your standardapiURLwill fail the handshake atsetup()). Your account team will also tell you whichmgf1scheme 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 + 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
<incode-flow> drop-inThe 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
<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
- 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'sonFinish - 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) pluswarmupWasm(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:
| 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 3Next 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 about 5 hours ago
